atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
97c05b741d8f0a39cec7b9e24bf5491f18c819c5
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/http/mod.rsRust165 lines5.1 KB
1
mod ctx;
2
mod inventory;
3
mod leaderboard;
4
mod links;
5
mod meta;
6
mod middleware;
7
mod parse;
8
mod registry;
9
mod resource;
10
mod response;
11
mod run;
12
mod runs;
13
mod shaft;
14
mod shame;
15
mod ui;
16
17
use std::{
18
    sync::Arc,
19
    time::{Duration, Instant},
20
};
21
22
use axum::{
23
    Router,
24
    extract::State,
25
    response::{Redirect, Response},
26
    routing::{MethodRouter, any},
27
};
28
29
use tower_http::catch_panic::CatchPanicLayer;
30
31
use crate::{
32
    config::Config,
33
    records::{Record, Records},
34
};
35
use ctx::Ctx;
36
use registry::{Lock, Registry};
37
use resource::Resource;
38
use run::RunResource;
39
40
pub struct AppState {
41
    runs: Registry,
42
    records: Records,
43
    config: Config,
44
}
45
46
impl AppState {
47
    pub fn new(config: Config) -> rusqlite::Result<Self> {
48
        let records = match &config.db {
49
            Some(path) => Records::open(path)?,
50
            None => Records::in_memory(),
51
        };
52
        Ok(Self {
53
            runs: Registry::default(),
54
            records,
55
            config,
56
        })
57
    }
58
59
    /// Records and forgets the runs that have gone idle. The server calls it on a timer.
60
    pub fn sweep(&self, now: Instant) -> rusqlite::Result<()> {
61
        for slot in self.runs.evict_idle(now, self.config.idle_ttl) {
62
            let record = match Lock::take(&slot) {
63
                Lock::Held(run) | Lock::Poisoned(run) => Record::of(&run),
64
            };
65
            self.records.save(&record)?;
66
        }
67
        Ok(())
68
    }
69
70
    #[cfg(test)]
71
    pub(crate) fn insert(&self, run: crate::game::Run) {
72
        self.runs.insert_new(run);
73
    }
74
}
75
76
impl Default for AppState {
77
    fn default() -> Self {
78
        Self::new(Config::default()).expect("an in-memory database always opens")
79
    }
80
}
81
82
type Shared = Arc<AppState>;
83
84
pub fn app(config: Config) -> rusqlite::Result<Router> {
85
    Ok(router(Arc::new(AppState::new(config)?)))
86
}
87
88
/// Sweeps idle runs into the records every minute, so a quiet server still keeps its graveyard.
89
pub async fn sweep_forever(state: Arc<AppState>) {
90
    let mut ticks = tokio::time::interval(Duration::from_secs(60));
91
    loop {
92
        ticks.tick().await;
93
        let swept = Arc::clone(&state);
94
        let result = tokio::task::spawn_blocking(move || swept.sweep(Instant::now())).await;
95
        if let Ok(Err(error)) = result {
96
            eprintln!("jelqd could not sweep idle runs into the records: {error}");
97
        }
98
    }
99
}
100
101
pub fn router(state: Shared) -> Router {
102
    let api = api(state);
103
    let ui = Router::new()
104
        .route("/ui", any(|| async { Redirect::permanent("/ui/") }))
105
        .route("/ui/", any(ui::page))
106
        .route("/ui/send", any(ui::send))
107
        .with_state(api.clone());
108
    api.merge(ui)
109
        .layer(CatchPanicLayer::custom(middleware::panicked))
110
}
111
112
fn global(resource: &'static Resource<Shared>) -> MethodRouter<Shared> {
113
    any(move |State(state): State<Shared>, ctx: Ctx| async move {
114
        offload(state, move |mut state| resource.dispatch(&mut state, &ctx)).await
115
    })
116
}
117
118
fn scoped(resource: &'static RunResource) -> MethodRouter<Shared> {
119
    any(move |State(state): State<Shared>, ctx: Ctx| async move {
120
        offload(state, move |state| run::serve(&state, ctx, resource)).await
121
    })
122
}
123
124
/// Game turns block, on SQLite always and on HTTP for every roll under the model oracle, so they
125
/// run on the blocking pool and leave the async workers free.
126
async fn offload<F>(state: Shared, work: F) -> Response
127
where
128
    F: FnOnce(Shared) -> Response + Send + 'static,
129
{
130
    tokio::task::spawn_blocking(move || work(state))
131
        .await
132
        .unwrap_or_else(|e| std::panic::resume_unwind(e.into_panic()))
133
}
134
135
fn api(state: Shared) -> Router {
136
    Router::new()
137
        .route("/", global(&meta::INDEX))
138
        .route("/runs", global(&meta::RUNS))
139
        .route("/runs/{id}", scoped(&runs::RUN))
140
        .route("/runs/{id}/length", scoped(&runs::LENGTH))
141
        .route("/runs/{id}/foreskin", scoped(&runs::FORESKIN))
142
        .route("/runs/{id}/inventory", scoped(&runs::INVENTORY))
143
        .route("/runs/{id}/inventory/{item}", scoped(&runs::ITEM))
144
        .route("/runs/{id}/techniques", scoped(&runs::TECHNIQUES))
145
        .route("/runs/{id}/drawer", scoped(&runs::DRAWER))
146
        .route("/runs/{id}/branches/{choice}", scoped(&runs::BRANCH))
147
        .route("/runs/{id}/kitchen", scoped(&runs::KITCHEN))
148
        .route("/runs/{id}/kitchen/watermelon", scoped(&runs::MELON))
149
        .route("/runs/{id}/re-read", scoped(&runs::REREAD))
150
        .route("/runs/{id}/floor", scoped(&runs::FLOOR))
151
        .route("/runs/{id}/shop", scoped(&runs::SHOP))
152
        .route("/runs/{id}/shop/{item}", scoped(&runs::BUY))
153
        .route("/runs/{id}/portal", scoped(&runs::PORTAL))
154
        .route("/runs/{id}/elder", scoped(&runs::ELDER))
155
        .route("/runs/{id}/grip", scoped(&runs::GRIP))
156
        .route("/runs/{id}/diagnosis", scoped(&runs::DIAGNOSIS))
157
        .route("/runs/{id}/footage", scoped(&runs::FOOTAGE))
158
        .route("/forum/threads/{year}", global(&meta::THREAD))
159
        .route("/graveyard", global(&meta::GRAVEYARD))
160
        .route("/leaderboard", global(&meta::LEADERBOARD))
161
        .fallback(meta::not_found)
162
        .layer(axum::middleware::from_fn(middleware::chrome))
163
        .layer(CatchPanicLayer::custom(middleware::panicked))
164
        .with_state(state)
165
}