an http based roguelike rpg about jelqing
Commit 159f0998263b
tarball15 files changed+635-92
@@ -1,2 +1,3 @@
11
/target22
/result3
+/jelqd.sqlite*@@ -88,12 +88,28 @@ dependencies = [
8888
]8989
9090
[[package]]91
+name = "bumpalo"92
+version = "3.20.3"93
+source = "registry+https://github.com/rust-lang/crates.io-index"94
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"95
+96
+[[package]]9197
name = "bytes"9298
version = "1.12.1"9399
source = "registry+https://github.com/rust-lang/crates.io-index"94100
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"95101
96102
[[package]]103
+name = "cc"104
+version = "1.8.0"105
+source = "registry+https://github.com/rust-lang/crates.io-index"106
+checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"107
+dependencies = [108
+ "find-msvc-tools",109
+ "shlex",110
+]111
+112
+[[package]]97113
name = "cfg-if"98114
version = "1.0.5"99115
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -150,6 +166,24 @@ dependencies = [
150166
]151167
152168
[[package]]169
+name = "fallible-iterator"170
+version = "0.3.0"171
+source = "registry+https://github.com/rust-lang/crates.io-index"172
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"173
+174
+[[package]]175
+name = "fallible-streaming-iterator"176
+version = "0.1.9"177
+source = "registry+https://github.com/rust-lang/crates.io-index"178
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"179
+180
+[[package]]181
+name = "find-msvc-tools"182
+version = "0.1.14"183
+source = "registry+https://github.com/rust-lang/crates.io-index"184
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"185
+186
+[[package]]153187
name = "flate2"154188
version = "1.1.10"155189
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -161,6 +195,12 @@ dependencies = [
161195
]162196
163197
[[package]]198
+name = "foldhash"199
+version = "0.2.0"200
+source = "registry+https://github.com/rust-lang/crates.io-index"201
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"202
+203
+[[package]]164204
name = "form_urlencoded"165205
version = "1.2.2"166206
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -228,6 +268,33 @@ dependencies = [
228268
]229269
230270
[[package]]271
+name = "hashbrown"272
+version = "0.16.1"273
+source = "registry+https://github.com/rust-lang/crates.io-index"274
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"275
+dependencies = [276
+ "foldhash",277
+]278
+279
+[[package]]280
+name = "hashbrown"281
+version = "0.17.1"282
+source = "registry+https://github.com/rust-lang/crates.io-index"283
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"284
+dependencies = [285
+ "foldhash",286
+]287
+288
+[[package]]289
+name = "hashlink"290
+version = "0.12.2"291
+source = "registry+https://github.com/rust-lang/crates.io-index"292
+checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"293
+dependencies = [294
+ "hashbrown 0.17.1",295
+]296
+297
+[[package]]231298
name = "hmac"232299
version = "0.12.1"233300
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -437,6 +504,7 @@ dependencies = [
437504
"hyper",438505
"hyper-util",439506
"rand",507
+ "rusqlite",440508
"serde",441509
"serde_json",442510
"sha2",@@ -447,12 +515,33 @@ dependencies = [
447515
]448516
449517
[[package]]518
+name = "js-sys"519
+version = "0.3.106"520
+source = "registry+https://github.com/rust-lang/crates.io-index"521
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"522
+dependencies = [523
+ "cfg-if",524
+ "wasm-bindgen",525
+]526
+527
+[[package]]450528
name = "libc"451529
version = "0.2.190"452530
source = "registry+https://github.com/rust-lang/crates.io-index"453531
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"454532
455533
[[package]]534
+name = "libsqlite3-sys"535
+version = "0.38.2"536
+source = "registry+https://github.com/rust-lang/crates.io-index"537
+checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"538
+dependencies = [539
+ "cc",540
+ "pkg-config",541
+ "vcpkg",542
+]543
+544
+[[package]]456545
name = "litemap"457546
version = "0.8.3"458547
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -522,6 +611,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
522611
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"523612
524613
[[package]]614
+name = "pkg-config"615
+version = "0.3.34"616
+source = "registry+https://github.com/rust-lang/crates.io-index"617
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"618
+619
+[[package]]525620
name = "potential_utf"526621
version = "0.1.6"527622
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -593,6 +688,37 @@ dependencies = [
593688
]594689
595690
[[package]]691
+name = "rsqlite-vfs"692
+version = "0.1.1"693
+source = "registry+https://github.com/rust-lang/crates.io-index"694
+checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"695
+dependencies = [696
+ "hashbrown 0.16.1",697
+ "thiserror",698
+]699
+700
+[[package]]701
+name = "rusqlite"702
+version = "0.40.2"703
+source = "registry+https://github.com/rust-lang/crates.io-index"704
+checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"705
+dependencies = [706
+ "bitflags",707
+ "fallible-iterator",708
+ "fallible-streaming-iterator",709
+ "hashlink",710
+ "libsqlite3-sys",711
+ "smallvec",712
+ "sqlite-wasm-rs",713
+]714
+715
+[[package]]716
+name = "rustversion"717
+version = "1.0.23"718
+source = "registry+https://github.com/rust-lang/crates.io-index"719
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"720
+721
+[[package]]596722
name = "ryu"597723
version = "1.0.23"598724
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -676,6 +802,12 @@ dependencies = [
676802
]677803
678804
[[package]]805
+name = "shlex"806
+version = "2.0.1"807
+source = "registry+https://github.com/rust-lang/crates.io-index"808
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"809
+810
+[[package]]679811
name = "simd-adler32"680812
version = "0.3.10"681813
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -704,6 +836,18 @@ dependencies = [
704836
]705837
706838
[[package]]839
+name = "sqlite-wasm-rs"840
+version = "0.5.5"841
+source = "registry+https://github.com/rust-lang/crates.io-index"842
+checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"843
+dependencies = [844
+ "cc",845
+ "js-sys",846
+ "rsqlite-vfs",847
+ "wasm-bindgen",848
+]849
+850
+[[package]]707851
name = "stable_deref_trait"708852
version = "1.2.1"709853
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -755,6 +899,26 @@ dependencies = [
755899
]756900
757901
[[package]]902
+name = "thiserror"903
+version = "2.0.21"904
+source = "registry+https://github.com/rust-lang/crates.io-index"905
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"906
+dependencies = [907
+ "thiserror-impl",908
+]909
+910
+[[package]]911
+name = "thiserror-impl"912
+version = "2.0.21"913
+source = "registry+https://github.com/rust-lang/crates.io-index"914
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"915
+dependencies = [916
+ "proc-macro2",917
+ "quote",918
+ "syn 3.0.7",919
+]920
+921
+[[package]]758922
name = "tinystr"759923
version = "0.8.4"760924
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -901,6 +1065,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
9011065
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"9021066
9031067
[[package]]1068
+name = "vcpkg"1069
+version = "0.2.15"1070
+source = "registry+https://github.com/rust-lang/crates.io-index"1071
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"1072
+1073
+[[package]]9041074
name = "version_check"9051075
version = "0.9.5"9061076
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -922,6 +1092,51 @@ dependencies = [
9221092
]9231093
9241094
[[package]]1095
+name = "wasm-bindgen"1096
+version = "0.2.129"1097
+source = "registry+https://github.com/rust-lang/crates.io-index"1098
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"1099
+dependencies = [1100
+ "cfg-if",1101
+ "once_cell",1102
+ "rustversion",1103
+ "wasm-bindgen-macro",1104
+ "wasm-bindgen-shared",1105
+]1106
+1107
+[[package]]1108
+name = "wasm-bindgen-macro"1109
+version = "0.2.129"1110
+source = "registry+https://github.com/rust-lang/crates.io-index"1111
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"1112
+dependencies = [1113
+ "quote",1114
+ "wasm-bindgen-macro-support",1115
+]1116
+1117
+[[package]]1118
+name = "wasm-bindgen-macro-support"1119
+version = "0.2.129"1120
+source = "registry+https://github.com/rust-lang/crates.io-index"1121
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"1122
+dependencies = [1123
+ "bumpalo",1124
+ "proc-macro2",1125
+ "quote",1126
+ "syn 3.0.7",1127
+ "wasm-bindgen-shared",1128
+]1129
+1130
+[[package]]1131
+name = "wasm-bindgen-shared"1132
+version = "0.2.129"1133
+source = "registry+https://github.com/rust-lang/crates.io-index"1134
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"1135
+dependencies = [1136
+ "unicode-ident",1137
+]1138
+1139
+[[package]]9251140
name = "windows-link"9261141
version = "0.2.1"9271142
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -14,6 +14,7 @@ httpdate = "1"
1414
hyper = { version = "1", features = ["http1", "server"] }1515
hyper-util = { version = "0.1", features = ["tokio"] }1616
rand = "0.9"17
+rusqlite = { version = "0.40.2", features = ["bundled"] }1718
serde = { version = "1", features = ["derive"] }1819
serde_json = "1"1920
sha2 = "0.10"@@ -1,4 +1,4 @@
1
-use std::time::Duration;1
+use std::{path::PathBuf, time::Duration};22
33
use crate::game::oracle::OracleTemplate;44
use crate::secret::Secret;@@ -8,6 +8,8 @@ pub struct Config {
88
pub secret: Secret,99
pub max_runs: usize,1010
pub idle_ttl: Duration,11
+ /// Where finished runs are kept. `None` keeps them in memory, which only tests want.12
+ pub db: Option<PathBuf>,1113
pub(crate) oracle: OracleTemplate,1214
}1315
@@ -18,6 +20,7 @@ impl Default for Config {
1820
secret: Secret::random(),1921
max_runs: 10_000,2022
idle_ttl: Duration::from_secs(6 * 60 * 60),23
+ db: None,2124
oracle: OracleTemplate::Rng,2225
}2326
}@@ -29,6 +32,11 @@ impl Config {
2932
let defaults = Self::default();3033
Self {3134
addr: var("JELQD_ADDR").unwrap_or(defaults.addr),35
+ db: Some(36
+ var("JELQD_DB")37
+ .unwrap_or_else(|| "jelqd.sqlite".into())38
+ .into(),39
+ ),3240
secret: var("JELQD_SECRET").map_or(defaults.secret, |s| Secret::new(s.into_bytes())),3341
oracle: match var("JELQD_ORACLE").as_deref() {3442
Some("clm") => OracleTemplate::Clm {@@ -144,9 +144,9 @@ impl Run {
144144
pub fn girth_respected(&self) -> bool {145145
self.girth >= GIRTH_RESPECTED146146
}147
+}147148
148
- /// A cylinder whose circumference is the girth.149
- pub fn volume(&self, length: f64) -> f64 {150
- self.girth * self.girth * length / (4.0 * std::f64::consts::PI)151
- }149
+/// A cylinder whose circumference is the girth.150
+pub fn volume(length: f64, girth: f64) -> f64 {151
+ girth * girth * length / (4.0 * std::f64::consts::PI)152152
}@@ -1,7 +1,7 @@
11
use axum::http::{StatusCode, header};22
use serde_json::json;33
4
-use super::{Floor, Outcome, Run, etag_matches, parse_credentials, round2};4
+use super::{Floor, Outcome, Run, body::volume, etag_matches, parse_credentials, round2};55
use crate::{66
headers::{SERVER_TIMING, X_DIAGNOSIS},77
problem::Problem,@@ -63,7 +63,7 @@ impl Run {
6363
self.shame += 15;6464
}6565
}66
- let volume = self.volume(self.length);66
+ let volume = volume(self.length, self.girth);6767
let foreskin = self.foreskin.unwrap_or(0.0);6868
let timing = format!(6969
"length;dur={:.2}, girth;dur={:.2}, foreskin;dur={foreskin:.2}, volume;desc=\"cubic inches\";dur={volume:.2}",@@ -15,7 +15,7 @@ mod techniques;
1515
mod turn;1616
mod utility;1717
18
-pub use body::PatchOp;18
+pub use body::{PatchOp, volume};1919
pub use forum::{ELDER_LAST_MODIFIED, FORUM_THREAD, forum_thread};2020
pub use items::{Item, Items, SHOP};2121
pub use outcome::{Outcome, Payload};@@ -26,6 +26,12 @@ pub(crate) enum OracleTemplate {
2626
Clm { url: String },2727
}2828
29
+impl OracleTemplate {30
+ pub(crate) fn blocks(&self) -> bool {31
+ matches!(self, Self::Clm { .. })32
+ }33
+}34
+2935
pub(crate) struct Clm {3036
url: String,3137
agent: ureq::Agent,@@ -1,7 +1,5 @@
11
//! Resources that do not belong to a single run.22
3
-use std::time::Instant;4
-53
use axum::{64
Json,75
http::{StatusCode, header},@@ -17,11 +15,14 @@ use super::{
1715
run,1816
};1917
use crate::{20
- game::{ELDER_LAST_MODIFIED, FORUM_THREAD, Run, balance::MM_PER_INCH, forum_thread},18
+ game::{ELDER_LAST_MODIFIED, FORUM_THREAD, Run, balance::MM_PER_INCH, forum_thread, volume},2119
headers::X_SEED,2220
problem::{PROBLEM_BASE, Problem},21
+ records::Record,2322
};2423
24
+use super::registry::hold;25
+2526
pub static INDEX: Resource<Shared> = Resource::new(&[Verb::new("GET", index)]);2627
pub static RUNS: Resource<Shared> = Resource::new(&[Verb::new("POST", create_run)]);2728
pub static THREAD: Resource<Shared> = Resource::new(&[Verb::new("GET", thread)]);@@ -68,21 +69,10 @@ fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {
6869
}6970
};7071
let config = &state.config;71
- let mut runs = state.runs();72
- runs.retain(|_, run| ctx.now.duration_since(run.last_seen) < config.idle_ttl);73
- if runs.len() >= config.max_runs {74
- let mut buried: Vec<(Instant, String)> = runs75
- .iter()76
- .filter(|(_, run)| run.ended.is_some())77
- .map(|(id, run)| (run.last_seen, id.clone()))78
- .collect();79
- buried.sort_unstable();80
- let excess = runs.len() + 1 - config.max_runs;81
- for (_, id) in buried.into_iter().take(excess) {82
- runs.remove(&id);83
- }72
+ for slot in state.runs.evict_idle(ctx.now, config.idle_ttl) {73
+ state.records.save(&Record::of(&hold(&slot)));8474
}85
- if runs.len() >= config.max_runs {75
+ if state.runs.len() >= config.max_runs {8676
return Problem::new(8777
StatusCode::SERVICE_UNAVAILABLE,8878
"server-full",@@ -93,7 +83,7 @@ fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {
9383
}9484
let id = loop {9585
let id = format!("{:032x}", rand::rng().random::<u128>());96
- if !runs.contains_key(&id) {86
+ if !state.runs.contains(&id) && state.records.find(&id).is_none() {9787
break id;9888
}9989
};@@ -117,7 +107,7 @@ fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {
117107
)118108
.into_response();119109
let response = run::finish(&mut run, ctx, &config.secret, response);120
- runs.insert(id, run);110
+ state.runs.insert(run);121111
response122112
}123113
@@ -146,16 +136,17 @@ fn thread(_: &mut Shared, ctx: &Ctx) -> Response {
146136
}147137
148138
fn graveyard(state: &mut Shared, _: &Ctx) -> Response {149
- let runs = state.runs();150
- let dead: Vec<Value> = runs151
- .values()152
- .filter_map(|run| {153
- let ending = run.ended.as_ref()?;139
+ let dead: Vec<Value> = state140
+ .records141
+ .dead()142
+ .into_iter()143
+ .filter_map(|record| {144
+ let ending = record.ending?;154145
Some(json!({155
- "public_id": run.public_id,156
- "cause": format!("{PROBLEM_BASE}{}", ending.cause.slug()),157
- "detail": ending.cause.detail(),158
- "turn": ending.turn,146
+ "public_id": record.public_id,147
+ "cause": format!("{PROBLEM_BASE}{}", ending.cause),148
+ "detail": ending.detail,149
+ "turn": record.turn,159150
}))160151
})161152
.collect();@@ -206,33 +197,38 @@ fn leaderboard(state: &mut Shared, ctx: &Ctx) -> Response {
206197
}207198
};208199
let include_seeded = ctx.query("seeded") == Some("include");209
- let runs = state.runs();210
- let mut entries: Vec<&Run> = runs211
- .values()212
- .filter(|run| include_seeded || !run.seeded)213
- .collect();214
- let key = |run: &Run| match sort {215
- Sort::SelfReported => run.self_reported(),216
- Sort::ContentLength => run.current_length(),217
- Sort::Volume => run.volume(run.self_reported()),200
+ let mut entries: Vec<Record> = state.runs.each(Record::of);201
+ entries.extend(202
+ state203
+ .records204
+ .all()205
+ .into_iter()206
+ .filter(|record| !state.runs.contains(&record.id)),207
+ );208
+ entries.retain(|record| include_seeded || !record.seeded);209
+ let key = |record: &Record| match sort {210
+ Sort::SelfReported => record.self_reported,211
+ Sort::ContentLength => record.length,212
+ Sort::Volume => volume(record.self_reported, record.girth),218213
};219214
entries.sort_by(|a, b| key(b).total_cmp(&key(a)));220215
let entries: Vec<Value> = entries221216
.into_iter()222
- .map(|run| {217
+ .map(|record| {223218
let mut entry = json!({224
- "public_id": run.public_id,225
- "status": run.status(),226
- "floor": run.floor.number(),227
- "self_reported_length": (run.self_reported() * 100.0).round() / 100.0,228
- "ranked": !run.seeded,229
- "viral": run.filmed_at.is_some(),219
+ "public_id": record.public_id,220
+ "status": record.status,221
+ "floor": record.floor,222
+ "self_reported_length": (record.self_reported * 100.0).round() / 100.0,223
+ "ranked": !record.seeded,224
+ "viral": record.viral,230225
});231226
if sort == Sort::Volume {232
- entry["volume"] = json!((run.volume(run.self_reported()) * 100.0).round() / 100.0);227
+ entry["volume"] =228
+ json!((volume(record.self_reported, record.girth) * 100.0).round() / 100.0);233229
}234230
if sort == Sort::ContentLength {235
- entry["content_length"] = json!((run.current_length() * MM_PER_INCH).round());231
+ entry["content_length"] = json!((record.length * MM_PER_INCH).round());236232
}237233
entry238234
})@@ -1,52 +1,54 @@
11
mod ctx;22
mod meta;33
mod middleware;4
+mod registry;45
mod resource;56
mod response;67
mod run;78
mod runs;89
mod ui;910
10
-use std::{11
- collections::HashMap,12
- sync::{Arc, Mutex, MutexGuard, PoisonError},13
-};11
+use std::sync::Arc;1412
1513
use axum::{1614
Router,1715
extract::State,18
- response::Redirect,16
+ response::{Redirect, Response},1917
routing::{MethodRouter, any},2018
};2119
2220
use tower_http::catch_panic::CatchPanicLayer;2321
24
-use crate::{config::Config, game::Run};22
+use crate::{config::Config, records::Records};2523
use ctx::Ctx;24
+use registry::Registry;2625
use resource::Resource;2726
use run::RunResource;2827
2928
pub struct AppState {30
- runs: Mutex<HashMap<String, Run>>,29
+ runs: Registry,30
+ records: Records,3131
config: Config,3232
}3333
3434
impl AppState {3535
pub fn new(config: Config) -> Self {36
+ let records = match &config.db {37
+ Some(path) => Records::open(path).unwrap_or_else(|e| {38
+ panic!("the records at {} could not be opened: {e}", path.display())39
+ }),40
+ None => Records::in_memory(),41
+ };3642
Self {37
- runs: Mutex::default(),43
+ runs: Registry::default(),44
+ records,3845
config,3946
}4047
}4148
42
- /// A panic mid-request must not take every other run down with it, so a poisoned lock is recovered.43
- fn runs(&self) -> MutexGuard<'_, HashMap<String, Run>> {44
- self.runs.lock().unwrap_or_else(PoisonError::into_inner)45
- }46
-4749
#[cfg(test)]48
- pub(crate) fn insert(&self, run: Run) {49
- self.runs().insert(run.id.clone(), run);50
+ pub(crate) fn insert(&self, run: crate::game::Run) {51
+ self.runs.insert(run);5052
}5153
}5254
@@ -74,17 +76,29 @@ pub fn router(state: Shared) -> Router {
7476
}7577
7678
fn global(resource: &'static Resource<Shared>) -> MethodRouter<Shared> {77
- any(78
- move |State(mut state): State<Shared>, ctx: Ctx| async move {79
- resource.dispatch(&mut state, &ctx)80
- },81
- )79
+ any(move |State(state): State<Shared>, ctx: Ctx| async move {80
+ offload(state, move |mut state| resource.dispatch(&mut state, &ctx)).await81
+ })8282
}8383
8484
fn scoped(resource: &'static RunResource) -> MethodRouter<Shared> {85
- any(86
- move |State(state): State<Shared>, ctx: Ctx| async move { run::serve(&state, ctx, resource) },87
- )85
+ any(move |State(state): State<Shared>, ctx: Ctx| async move {86
+ offload(state, move |state| run::serve(&state, ctx, resource)).await87
+ })88
+}89
+90
+/// The model oracle blocks on HTTP for every roll, so its turns run on the blocking pool and91
+/// leave the async workers free. Dice are quick enough to roll in place.92
+async fn offload<F>(state: Shared, work: F) -> Response93
+where94
+ F: FnOnce(Shared) -> Response + Send + 'static,95
+{96
+ if !state.config.oracle.blocks() {97
+ return work(state);98
+ }99
+ tokio::task::spawn_blocking(move || work(state))100
+ .await101
+ .unwrap_or_else(|e| std::panic::resume_unwind(e.into_panic()))88102
}89103
90104
fn api(state: Shared) -> Router {@@ -0,0 +1,77 @@
1
+//! The live runs. The table is only locked long enough to find a run, and each run has its own2
+//! lock, so one slow turn never holds up anyone else's.3
+4
+use std::{5
+ collections::HashMap,6
+ sync::{Arc, Mutex, MutexGuard, PoisonError, RwLock, TryLockError},7
+ time::{Duration, Instant},8
+};9
+10
+use crate::game::Run;11
+12
+pub type Slot = Arc<Mutex<Run>>;13
+14
+#[derive(Default)]15
+pub struct Registry {16
+ runs: RwLock<HashMap<String, Slot>>,17
+}18
+19
+/// A panic mid-request must not take the run down with it, so a poisoned lock is recovered.20
+pub fn hold(slot: &Slot) -> MutexGuard<'_, Run> {21
+ slot.lock().unwrap_or_else(PoisonError::into_inner)22
+}23
+24
+impl Registry {25
+ pub fn get(&self, id: &str) -> Option<Slot> {26
+ self.read().get(id).cloned()27
+ }28
+29
+ pub fn len(&self) -> usize {30
+ self.read().len()31
+ }32
+33
+ pub fn insert(&self, run: Run) {34
+ let id = run.id.clone();35
+ self.write().insert(id, Arc::new(Mutex::new(run)));36
+ }37
+38
+ pub fn remove(&self, id: &str) {39
+ self.write().remove(id);40
+ }41
+42
+ pub fn contains(&self, id: &str) -> bool {43
+ self.read().contains_key(id)44
+ }45
+46
+ /// Every live run, each visited under its own lock.47
+ pub fn each<T>(&self, mut visit: impl FnMut(&Run) -> T) -> Vec<T> {48
+ let slots: Vec<Slot> = self.read().values().cloned().collect();49
+ slots.iter().map(|slot| visit(&hold(slot))).collect()50
+ }51
+52
+ /// Takes out the runs nobody has touched for `ttl`, and hands them back for the record.53
+ pub fn evict_idle(&self, now: Instant, ttl: Duration) -> Vec<Slot> {54
+ let mut runs = self.write();55
+ let idle: Vec<String> = runs56
+ .iter()57
+ .filter(|(_, slot)| {58
+ let idle = |run: &Run| now.duration_since(run.last_seen) >= ttl;59
+ match slot.try_lock() {60
+ Ok(run) => idle(&run),61
+ Err(TryLockError::Poisoned(poisoned)) => idle(&poisoned.into_inner()),62
+ Err(TryLockError::WouldBlock) => false,63
+ }64
+ })65
+ .map(|(id, _)| id.clone())66
+ .collect();67
+ idle.iter().filter_map(|id| runs.remove(id)).collect()68
+ }69
+70
+ fn read(&self) -> std::sync::RwLockReadGuard<'_, HashMap<String, Slot>> {71
+ self.runs.read().unwrap_or_else(PoisonError::into_inner)72
+ }73
+74
+ fn write(&self) -> std::sync::RwLockWriteGuard<'_, HashMap<String, Slot>> {75
+ self.runs.write().unwrap_or_else(PoisonError::into_inner)76
+ }77
+}@@ -7,7 +7,9 @@ use axum::{
77
use hyper_util::rt::TokioIo;88
use tokio::io::AsyncWriteExt;99
10
-use super::{AppState, ctx::Ctx, meta, middleware::Compress, resource::Resource, runs};10
+use super::{11
+ AppState, ctx::Ctx, meta, middleware::Compress, registry::hold, resource::Resource, runs,12
+};1113
use crate::{1214
game::{1315
Elder, FORUM_THREAD, Floor, Run, SHOP, Story,@@ -19,6 +21,7 @@ use crate::{
1921
},2022
locale::Locale,2123
problem::{PROBLEM_BASE, Problem},24
+ records::Record,2225
secret::Secret,2326
};2427
@@ -41,31 +44,51 @@ pub struct RunResource {
4144
pub struct Greeting(pub String);4245
4346
pub fn serve(state: &AppState, mut ctx: Ctx, target: &RunResource) -> Response {44
- let mut runs = state.runs();45
- let Some(run) = runs.get_mut(ctx.param("id")) else {46
- return Problem::new(47
- StatusCode::NOT_FOUND,48
- "no-such-run",49
- "There is no such run. There may never have been.",50
- )51
- .into_response();47
+ let id = ctx.param("id").to_owned();48
+ let Some(slot) = state.runs.get(&id) else {49
+ return match state.records.find(&id) {50
+ Some(Record {51
+ ending: Some(ending),52
+ turn,53
+ ..54
+ }) => gone(&ending.cause, turn),55
+ _ => Problem::new(56
+ StatusCode::NOT_FOUND,57
+ "no-such-run",58
+ "There is no such run. There may never have been.",59
+ )60
+ .into_response(),61
+ };5262
};63
+ let mut guard = hold(&slot);64
+ let run = &mut *guard;5365
if let Some(ending) = &run.ended {54
- return Problem::new(StatusCode::GONE, "run-ended", "This run has ended.")55
- .with("cause", format!("{PROBLEM_BASE}{}", ending.cause.slug()))56
- .with("turn", ending.turn)57
- .with("graveyard", "/graveyard")58
- .into_response();66
+ return gone(ending.cause.slug(), ending.turn);5967
}6068
run.last_seen = ctx.now;6169
run.observed = None;6270
let response =6371
gate(run, &ctx, target.scope).unwrap_or_else(|| target.resource.dispatch(run, &ctx));6472
let response = finish(run, &ctx, &state.config.secret, response);65
- drop(runs);73
+ if run.ended.is_some() || run.clinic.cured {74
+ state.records.save(&Record::of(run));75
+ }76
+ let ended = run.ended.is_some();77
+ drop(guard);78
+ if ended {79
+ state.runs.remove(&id);80
+ }6681
greet(response, &mut ctx)6782
}6883
84
+fn gone(cause: &str, turn: u32) -> Response {85
+ Problem::new(StatusCode::GONE, "run-ended", "This run has ended.")86
+ .with("cause", format!("{PROBLEM_BASE}{cause}"))87
+ .with("turn", turn)88
+ .with("graveyard", "/graveyard")89
+ .into_response()90
+}91
+6992
fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {7093
if scope == Scope::Pockets {7194
return None;@@ -4,6 +4,7 @@ mod headers;
44
mod http;55
mod locale;66
mod problem;7
+mod records;78
mod secret;89
910
#[cfg(test)]@@ -0,0 +1,176 @@
1
+//! Finished and finishing runs, kept in SQLite so the graveyard and the leaderboard outlive the process.2
+3
+use std::{4
+ path::Path,5
+ sync::{Mutex, MutexGuard, PoisonError},6
+ time::{SystemTime, UNIX_EPOCH},7
+};8
+9
+use rusqlite::{Connection, OptionalExtension, Row, params};10
+11
+use crate::game::Run;12
+13
+const SCHEMA: &str = "14
+CREATE TABLE IF NOT EXISTS runs (15
+ id TEXT PRIMARY KEY,16
+ public_id TEXT NOT NULL,17
+ seeded INTEGER NOT NULL,18
+ status TEXT NOT NULL,19
+ cause TEXT,20
+ detail TEXT,21
+ turn INTEGER NOT NULL,22
+ floor INTEGER NOT NULL,23
+ length REAL NOT NULL,24
+ self_reported REAL NOT NULL,25
+ girth REAL NOT NULL,26
+ viral INTEGER NOT NULL,27
+ recorded_at INTEGER NOT NULL28
+);29
+CREATE INDEX IF NOT EXISTS runs_by_cause ON runs (cause);30
+";31
+32
+/// What the graveyard and the leaderboard need to know about a run, live or long gone.33
+pub struct Record {34
+ pub id: String,35
+ pub public_id: String,36
+ pub seeded: bool,37
+ pub status: String,38
+ pub ending: Option<Ending>,39
+ pub turn: u32,40
+ pub floor: u8,41
+ pub length: f64,42
+ pub self_reported: f64,43
+ pub girth: f64,44
+ pub viral: bool,45
+}46
+47
+pub struct Ending {48
+ pub cause: String,49
+ pub detail: String,50
+}51
+52
+impl Record {53
+ pub fn of(run: &Run) -> Self {54
+ Self {55
+ id: run.id.clone(),56
+ public_id: run.public_id.clone(),57
+ seeded: run.seeded,58
+ status: run.status().to_owned(),59
+ ending: run.ended.as_ref().map(|ending| Ending {60
+ cause: ending.cause.slug().to_owned(),61
+ detail: ending.cause.detail().to_owned(),62
+ }),63
+ turn: run.ended.as_ref().map_or(run.turn, |ending| ending.turn),64
+ floor: run.floor.number(),65
+ length: run.current_length(),66
+ self_reported: run.self_reported(),67
+ girth: run.girth,68
+ viral: run.filmed_at.is_some(),69
+ }70
+ }71
+72
+ fn from_row(row: &Row) -> rusqlite::Result<Self> {73
+ let cause: Option<String> = row.get("cause")?;74
+ let detail: Option<String> = row.get("detail")?;75
+ Ok(Self {76
+ id: row.get("id")?,77
+ public_id: row.get("public_id")?,78
+ seeded: row.get("seeded")?,79
+ status: row.get("status")?,80
+ ending: cause81
+ .zip(detail)82
+ .map(|(cause, detail)| Ending { cause, detail }),83
+ turn: row.get("turn")?,84
+ floor: row.get("floor")?,85
+ length: row.get("length")?,86
+ self_reported: row.get("self_reported")?,87
+ girth: row.get("girth")?,88
+ viral: row.get("viral")?,89
+ })90
+ }91
+}92
+93
+pub struct Records {94
+ db: Mutex<Connection>,95
+}96
+97
+impl Records {98
+ /// WAL lets the leaderboard read while a run is being buried.99
+ pub fn open(path: &Path) -> rusqlite::Result<Self> {100
+ let db = Connection::open(path)?;101
+ db.pragma_update(None, "journal_mode", "WAL")?;102
+ db.pragma_update(None, "synchronous", "NORMAL")?;103
+ Self::with(db)104
+ }105
+106
+ pub fn in_memory() -> Self {107
+ Self::with(Connection::open_in_memory().expect("an in-memory database always opens"))108
+ .expect("the schema applies to an empty database")109
+ }110
+111
+ fn with(db: Connection) -> rusqlite::Result<Self> {112
+ db.execute_batch(SCHEMA)?;113
+ Ok(Self { db: Mutex::new(db) })114
+ }115
+116
+ fn db(&self) -> MutexGuard<'_, Connection> {117
+ self.db.lock().unwrap_or_else(PoisonError::into_inner)118
+ }119
+120
+ /// A record that cannot be written is a 500, the same as any other broken promise to persist.121
+ pub fn save(&self, record: &Record) {122
+ let recorded_at = SystemTime::now()123
+ .duration_since(UNIX_EPOCH)124
+ .map_or(0, |since| {125
+ i64::try_from(since.as_secs()).unwrap_or(i64::MAX)126
+ });127
+ self.db()128
+ .execute(129
+ "INSERT OR REPLACE INTO runs (id, public_id, seeded, status, cause, detail, turn,130
+ floor, length, self_reported, girth, viral, recorded_at)131
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",132
+ params![133
+ record.id,134
+ record.public_id,135
+ record.seeded,136
+ record.status,137
+ record.ending.as_ref().map(|e| &e.cause),138
+ record.ending.as_ref().map(|e| &e.detail),139
+ record.turn,140
+ record.floor,141
+ record.length,142
+ record.self_reported,143
+ record.girth,144
+ record.viral,145
+ recorded_at,146
+ ],147
+ )148
+ .unwrap_or_else(|e| panic!("run {} could not be recorded: {e}", record.id));149
+ }150
+151
+ pub fn find(&self, id: &str) -> Option<Record> {152
+ self.db()153
+ .query_row("SELECT * FROM runs WHERE id = ?1", [id], Record::from_row)154
+ .optional()155
+ .unwrap_or_else(|e| panic!("the records could not be read: {e}"))156
+ }157
+158
+ pub fn dead(&self) -> Vec<Record> {159
+ self.query("SELECT * FROM runs WHERE cause IS NOT NULL ORDER BY recorded_at")160
+ }161
+162
+ pub fn all(&self) -> Vec<Record> {163
+ self.query("SELECT * FROM runs")164
+ }165
+166
+ fn query(&self, sql: &str) -> Vec<Record> {167
+ let db = self.db();168
+ let mut statement = db169
+ .prepare_cached(sql)170
+ .unwrap_or_else(|e| panic!("the records could not be read: {e}"));171
+ statement172
+ .query_map([], Record::from_row)173
+ .and_then(Iterator::collect)174
+ .unwrap_or_else(|e| panic!("the records could not be read: {e}"))175
+ }176
+}@@ -2056,3 +2056,28 @@ async fn the_forum_respects_volume() {
20562056
assert_eq!(board["entries"][0]["public_id"], "stout");20572057
assert!(board["entries"][0]["volume"].as_f64().unwrap() > 0.0);20582058
}2059
+2060
+#[tokio::test]2061
+async fn the_dead_outlive_the_server() {2062
+ let path = std::env::temp_dir().join(format!("jelqd-{}.sqlite", std::process::id()));2063
+ let config = || Config {2064
+ db: Some(path.clone()),2065
+ ..Config::default()2066
+ };2067
+ let first = crate::app(config());2068
+ let created = send(&first, "POST", "/runs", &[], "").await;2069
+ let location = header(&created, "location").to_owned();2070
+ send(&first, "DELETE", &location, &[], "").await;2071
+ drop(first);2072
+2073
+ let second = crate::app(config());2074
+ let gone = send(&second, "GET", &location, &[], "").await;2075
+ assert_eq!(gone.status(), StatusCode::GONE);2076
+ let graveyard = json(send(&second, "GET", "/graveyard", &[], "").await).await;2077
+ assert_eq!(graveyard["runs"].as_array().unwrap().len(), 1);2078
+ let board = json(send(&second, "GET", "/leaderboard", &[], "").await).await;2079
+ assert_eq!(board["entries"][0]["status"], "gone");2080
+ for suffix in ["", "-wal", "-shm"] {2081
+ let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));2082
+ }2083
+}