atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
a9b016e4b248ebfb4326a7881143856a1fe77252
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git

Blame: src/game.rs

a9b016e4b248atagen2026-10-10 08:12 UTC1
use std::time::{Duration, Instant};
a9b016e4b248atagen2026-10-10 08:12 UTC2
a9b016e4b248atagen2026-10-10 08:12 UTC3
use axum::{
a9b016e4b248atagen2026-10-10 08:12 UTC4
    Json,
a9b016e4b248atagen2026-10-10 08:12 UTC5
    body::Body,
a9b016e4b248atagen2026-10-10 08:12 UTC6
    http::{HeaderName, HeaderValue, StatusCode, header},
a9b016e4b248atagen2026-10-10 08:12 UTC7
    response::{IntoResponse, Response},
a9b016e4b248atagen2026-10-10 08:12 UTC8
};
a9b016e4b248atagen2026-10-10 08:12 UTC9
use rand::{Rng, SeedableRng, rngs::StdRng};
a9b016e4b248atagen2026-10-10 08:12 UTC10
use serde::Deserialize;
a9b016e4b248atagen2026-10-10 08:12 UTC11
use serde_json::{Value, json};
a9b016e4b248atagen2026-10-10 08:12 UTC12
a9b016e4b248atagen2026-10-10 08:12 UTC13
use crate::problem::Problem;
a9b016e4b248atagen2026-10-10 08:12 UTC14
a9b016e4b248atagen2026-10-10 08:12 UTC15
pub const MAX_STAMINA: i32 = 10;
a9b016e4b248atagen2026-10-10 08:12 UTC16
pub const SUSPICION_LIMIT: u8 = 5;
a9b016e4b248atagen2026-10-10 08:12 UTC17
pub const BRANCH_TURN: u32 = 10;
a9b016e4b248atagen2026-10-10 08:12 UTC18
pub const APPOINTMENT_TURN: u32 = 20;
a9b016e4b248atagen2026-10-10 08:12 UTC19
pub const REFRACTORY: Duration = Duration::from_secs(30);
a9b016e4b248atagen2026-10-10 08:12 UTC20
pub const SELF_REPORT_INFLATION: f64 = 1.5;
a9b016e4b248atagen2026-10-10 08:12 UTC21
pub const MM_PER_INCH: f64 = 25.4;
a9b016e4b248atagen2026-10-10 08:12 UTC22
pub const EXCUSES: [&str; 3] = ["changing", "homework", "pulled a muscle"];
a9b016e4b248atagen2026-10-10 08:12 UTC23
pub const LUBRICANTS: [&str; 5] = [
a9b016e4b248atagen2026-10-10 08:12 UTC24
    "application/baby-oil",
a9b016e4b248atagen2026-10-10 08:12 UTC25
    "application/coconut-oil",
a9b016e4b248atagen2026-10-10 08:12 UTC26
    "application/lotion",
a9b016e4b248atagen2026-10-10 08:12 UTC27
    "application/vaseline",
a9b016e4b248atagen2026-10-10 08:12 UTC28
    "text/plain; charset=spit",
a9b016e4b248atagen2026-10-10 08:12 UTC29
];
a9b016e4b248atagen2026-10-10 08:12 UTC30
pub const INTAKE: [&str; 3] = [
a9b016e4b248atagen2026-10-10 08:12 UTC31
    "The clinic does not accept excuses.",
a9b016e4b248atagen2026-10-10 08:12 UTC32
    "The clinic accepts one token. You have not been carrying it.",
a9b016e4b248atagen2026-10-10 08:12 UTC33
    "Be honest.",
a9b016e4b248atagen2026-10-10 08:12 UTC34
];
a9b016e4b248atagen2026-10-10 08:12 UTC35
a9b016e4b248atagen2026-10-10 08:12 UTC36
pub const SERVER_TIMING: HeaderName = HeaderName::from_static("server-timing");
a9b016e4b248atagen2026-10-10 08:12 UTC37
pub const X_DIAGNOSIS: HeaderName = HeaderName::from_static("x-diagnosis");
a9b016e4b248atagen2026-10-10 08:12 UTC38
a9b016e4b248atagen2026-10-10 08:12 UTC39
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
a9b016e4b248atagen2026-10-10 08:12 UTC40
pub enum Lube {
a9b016e4b248atagen2026-10-10 08:12 UTC41
    BabyOil,
a9b016e4b248atagen2026-10-10 08:12 UTC42
    CoconutOil,
a9b016e4b248atagen2026-10-10 08:12 UTC43
    Lotion,
a9b016e4b248atagen2026-10-10 08:12 UTC44
    Vaseline,
a9b016e4b248atagen2026-10-10 08:12 UTC45
    Spit,
a9b016e4b248atagen2026-10-10 08:12 UTC46
}
a9b016e4b248atagen2026-10-10 08:12 UTC47
a9b016e4b248atagen2026-10-10 08:12 UTC48
impl Lube {
a9b016e4b248atagen2026-10-10 08:12 UTC49
    pub fn from_content_type(content_type: Option<&str>) -> Result<Option<Self>, Problem> {
a9b016e4b248atagen2026-10-10 08:12 UTC50
        let Some(raw) = content_type else {
a9b016e4b248atagen2026-10-10 08:12 UTC51
            return Ok(None);
a9b016e4b248atagen2026-10-10 08:12 UTC52
        };
a9b016e4b248atagen2026-10-10 08:12 UTC53
        let lowered = raw.to_ascii_lowercase();
a9b016e4b248atagen2026-10-10 08:12 UTC54
        let mut parts = lowered.split(';').map(str::trim);
a9b016e4b248atagen2026-10-10 08:12 UTC55
        let essence = parts.next().unwrap_or_default();
a9b016e4b248atagen2026-10-10 08:12 UTC56
        let spit = parts.any(|p| p.replace(' ', "") == "charset=spit");
a9b016e4b248atagen2026-10-10 08:12 UTC57
        match essence {
a9b016e4b248atagen2026-10-10 08:12 UTC58
            "application/baby-oil" => Ok(Some(Self::BabyOil)),
a9b016e4b248atagen2026-10-10 08:12 UTC59
            "application/coconut-oil" => Ok(Some(Self::CoconutOil)),
a9b016e4b248atagen2026-10-10 08:12 UTC60
            "application/lotion" => Ok(Some(Self::Lotion)),
a9b016e4b248atagen2026-10-10 08:12 UTC61
            "application/vaseline" => Ok(Some(Self::Vaseline)),
a9b016e4b248atagen2026-10-10 08:12 UTC62
            "text/plain" if spit => Ok(Some(Self::Spit)),
a9b016e4b248atagen2026-10-10 08:12 UTC63
            e if e.starts_with("image/") || e.starts_with("video/") => Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC64
                StatusCode::UNSUPPORTED_MEDIA_TYPE,
a9b016e4b248atagen2026-10-10 08:12 UTC65
                "no",
a9b016e4b248atagen2026-10-10 08:12 UTC66
                "No.",
a9b016e4b248atagen2026-10-10 08:12 UTC67
            )),
a9b016e4b248atagen2026-10-10 08:12 UTC68
            _ => Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC69
                StatusCode::UNSUPPORTED_MEDIA_TYPE,
a9b016e4b248atagen2026-10-10 08:12 UTC70
                "unsupported-lubricant",
a9b016e4b248atagen2026-10-10 08:12 UTC71
                format!("{essence} is not a lubricant."),
a9b016e4b248atagen2026-10-10 08:12 UTC72
            )
a9b016e4b248atagen2026-10-10 08:12 UTC73
            .with("lubricants", json!(LUBRICANTS))),
a9b016e4b248atagen2026-10-10 08:12 UTC74
        }
a9b016e4b248atagen2026-10-10 08:12 UTC75
    }
a9b016e4b248atagen2026-10-10 08:12 UTC76
a9b016e4b248atagen2026-10-10 08:12 UTC77
    fn modifier(self) -> i32 {
a9b016e4b248atagen2026-10-10 08:12 UTC78
        match self {
a9b016e4b248atagen2026-10-10 08:12 UTC79
            Self::BabyOil => 1,
a9b016e4b248atagen2026-10-10 08:12 UTC80
            Self::CoconutOil => 3,
a9b016e4b248atagen2026-10-10 08:12 UTC81
            Self::Lotion => 0,
a9b016e4b248atagen2026-10-10 08:12 UTC82
            Self::Vaseline => 2,
a9b016e4b248atagen2026-10-10 08:12 UTC83
            Self::Spit => -2,
a9b016e4b248atagen2026-10-10 08:12 UTC84
        }
a9b016e4b248atagen2026-10-10 08:12 UTC85
    }
a9b016e4b248atagen2026-10-10 08:12 UTC86
}
a9b016e4b248atagen2026-10-10 08:12 UTC87
a9b016e4b248atagen2026-10-10 08:12 UTC88
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
a9b016e4b248atagen2026-10-10 08:12 UTC89
pub enum Technique {
a9b016e4b248atagen2026-10-10 08:12 UTC90
    Warmup,
a9b016e4b248atagen2026-10-10 08:12 UTC91
    Ice,
a9b016e4b248atagen2026-10-10 08:12 UTC92
    Wet,
a9b016e4b248atagen2026-10-10 08:12 UTC93
    Dry,
a9b016e4b248atagen2026-10-10 08:12 UTC94
    Stretch,
a9b016e4b248atagen2026-10-10 08:12 UTC95
    Uli,
a9b016e4b248atagen2026-10-10 08:12 UTC96
}
a9b016e4b248atagen2026-10-10 08:12 UTC97
a9b016e4b248atagen2026-10-10 08:12 UTC98
#[derive(Clone, Copy)]
a9b016e4b248atagen2026-10-10 08:12 UTC99
enum LubeRule {
a9b016e4b248atagen2026-10-10 08:12 UTC100
    Required,
a9b016e4b248atagen2026-10-10 08:12 UTC101
    Forbidden,
a9b016e4b248atagen2026-10-10 08:12 UTC102
    Optional,
a9b016e4b248atagen2026-10-10 08:12 UTC103
}
a9b016e4b248atagen2026-10-10 08:12 UTC104
a9b016e4b248atagen2026-10-10 08:12 UTC105
struct Profile {
a9b016e4b248atagen2026-10-10 08:12 UTC106
    dc: i32,
a9b016e4b248atagen2026-10-10 08:12 UTC107
    length: f64,
a9b016e4b248atagen2026-10-10 08:12 UTC108
    girth: f64,
a9b016e4b248atagen2026-10-10 08:12 UTC109
    foreskin: Option<(f64, f64)>,
a9b016e4b248atagen2026-10-10 08:12 UTC110
    foreskin_growth: f64,
a9b016e4b248atagen2026-10-10 08:12 UTC111
    lube: LubeRule,
a9b016e4b248atagen2026-10-10 08:12 UTC112
}
a9b016e4b248atagen2026-10-10 08:12 UTC113
a9b016e4b248atagen2026-10-10 08:12 UTC114
impl Technique {
a9b016e4b248atagen2026-10-10 08:12 UTC115
    pub const ALL: [Self; 6] = [
a9b016e4b248atagen2026-10-10 08:12 UTC116
        Self::Warmup,
a9b016e4b248atagen2026-10-10 08:12 UTC117
        Self::Ice,
a9b016e4b248atagen2026-10-10 08:12 UTC118
        Self::Wet,
a9b016e4b248atagen2026-10-10 08:12 UTC119
        Self::Dry,
a9b016e4b248atagen2026-10-10 08:12 UTC120
        Self::Stretch,
a9b016e4b248atagen2026-10-10 08:12 UTC121
        Self::Uli,
a9b016e4b248atagen2026-10-10 08:12 UTC122
    ];
a9b016e4b248atagen2026-10-10 08:12 UTC123
a9b016e4b248atagen2026-10-10 08:12 UTC124
    pub fn name(self) -> &'static str {
a9b016e4b248atagen2026-10-10 08:12 UTC125
        match self {
a9b016e4b248atagen2026-10-10 08:12 UTC126
            Self::Warmup => "warmup",
a9b016e4b248atagen2026-10-10 08:12 UTC127
            Self::Ice => "ice",
a9b016e4b248atagen2026-10-10 08:12 UTC128
            Self::Wet => "wet",
a9b016e4b248atagen2026-10-10 08:12 UTC129
            Self::Dry => "dry",
a9b016e4b248atagen2026-10-10 08:12 UTC130
            Self::Stretch => "stretch",
a9b016e4b248atagen2026-10-10 08:12 UTC131
            Self::Uli => "uli",
a9b016e4b248atagen2026-10-10 08:12 UTC132
        }
a9b016e4b248atagen2026-10-10 08:12 UTC133
    }
a9b016e4b248atagen2026-10-10 08:12 UTC134
a9b016e4b248atagen2026-10-10 08:12 UTC135
    pub fn parse(name: &str) -> Option<Self> {
a9b016e4b248atagen2026-10-10 08:12 UTC136
        Self::ALL.into_iter().find(|t| t.name() == name)
a9b016e4b248atagen2026-10-10 08:12 UTC137
    }
a9b016e4b248atagen2026-10-10 08:12 UTC138
a9b016e4b248atagen2026-10-10 08:12 UTC139
    fn profile(self) -> Option<Profile> {
a9b016e4b248atagen2026-10-10 08:12 UTC140
        let profile = match self {
a9b016e4b248atagen2026-10-10 08:12 UTC141
            Self::Warmup | Self::Ice => return None,
a9b016e4b248atagen2026-10-10 08:12 UTC142
            Self::Wet => Profile {
a9b016e4b248atagen2026-10-10 08:12 UTC143
                dc: 10,
a9b016e4b248atagen2026-10-10 08:12 UTC144
                length: 0.010,
a9b016e4b248atagen2026-10-10 08:12 UTC145
                girth: 0.005,
a9b016e4b248atagen2026-10-10 08:12 UTC146
                foreskin: None,
a9b016e4b248atagen2026-10-10 08:12 UTC147
                foreskin_growth: 0.0,
a9b016e4b248atagen2026-10-10 08:12 UTC148
                lube: LubeRule::Required,
a9b016e4b248atagen2026-10-10 08:12 UTC149
            },
a9b016e4b248atagen2026-10-10 08:12 UTC150
            Self::Dry => Profile {
a9b016e4b248atagen2026-10-10 08:12 UTC151
                dc: 12,
a9b016e4b248atagen2026-10-10 08:12 UTC152
                length: 0.008,
a9b016e4b248atagen2026-10-10 08:12 UTC153
                girth: 0.003,
a9b016e4b248atagen2026-10-10 08:12 UTC154
                foreskin: Some((0.8, 2.0)),
a9b016e4b248atagen2026-10-10 08:12 UTC155
                foreskin_growth: 0.01,
a9b016e4b248atagen2026-10-10 08:12 UTC156
                lube: LubeRule::Forbidden,
a9b016e4b248atagen2026-10-10 08:12 UTC157
            },
a9b016e4b248atagen2026-10-10 08:12 UTC158
            Self::Stretch => Profile {
a9b016e4b248atagen2026-10-10 08:12 UTC159
                dc: 11,
a9b016e4b248atagen2026-10-10 08:12 UTC160
                length: 0.015,
a9b016e4b248atagen2026-10-10 08:12 UTC161
                girth: 0.0,
a9b016e4b248atagen2026-10-10 08:12 UTC162
                foreskin: Some((0.0, 1.8)),
a9b016e4b248atagen2026-10-10 08:12 UTC163
                foreskin_growth: 0.03,
a9b016e4b248atagen2026-10-10 08:12 UTC164
                lube: LubeRule::Optional,
a9b016e4b248atagen2026-10-10 08:12 UTC165
            },
a9b016e4b248atagen2026-10-10 08:12 UTC166
            Self::Uli => Profile {
a9b016e4b248atagen2026-10-10 08:12 UTC167
                dc: 15,
a9b016e4b248atagen2026-10-10 08:12 UTC168
                length: 0.002,
a9b016e4b248atagen2026-10-10 08:12 UTC169
                girth: 0.020,
a9b016e4b248atagen2026-10-10 08:12 UTC170
                foreskin: None,
a9b016e4b248atagen2026-10-10 08:12 UTC171
                foreskin_growth: 0.0,
a9b016e4b248atagen2026-10-10 08:12 UTC172
                lube: LubeRule::Optional,
a9b016e4b248atagen2026-10-10 08:12 UTC173
            },
a9b016e4b248atagen2026-10-10 08:12 UTC174
        };
a9b016e4b248atagen2026-10-10 08:12 UTC175
        Some(profile)
a9b016e4b248atagen2026-10-10 08:12 UTC176
    }
a9b016e4b248atagen2026-10-10 08:12 UTC177
a9b016e4b248atagen2026-10-10 08:12 UTC178
    pub fn describe(self, forum_read: bool) -> Value {
a9b016e4b248atagen2026-10-10 08:12 UTC179
        let Some(p) = self.profile() else {
a9b016e4b248atagen2026-10-10 08:12 UTC180
            let effect = match self {
a9b016e4b248atagen2026-10-10 08:12 UTC181
                Self::Warmup => "EQ +25. Satisfies the warm-up precondition.",
a9b016e4b248atagen2026-10-10 08:12 UTC182
                _ => "EQ -40. Consumes an ice pack.",
a9b016e4b248atagen2026-10-10 08:12 UTC183
            };
a9b016e4b248atagen2026-10-10 08:12 UTC184
            return json!({ "name": self.name(), "effect": effect });
a9b016e4b248atagen2026-10-10 08:12 UTC185
        };
a9b016e4b248atagen2026-10-10 08:12 UTC186
        let lubricant = match p.lube {
a9b016e4b248atagen2026-10-10 08:12 UTC187
            LubeRule::Required => "required",
a9b016e4b248atagen2026-10-10 08:12 UTC188
            LubeRule::Forbidden => "forbidden",
a9b016e4b248atagen2026-10-10 08:12 UTC189
            LubeRule::Optional => "optional",
a9b016e4b248atagen2026-10-10 08:12 UTC190
        };
a9b016e4b248atagen2026-10-10 08:12 UTC191
        let foreskin = p
a9b016e4b248atagen2026-10-10 08:12 UTC192
            .foreskin
a9b016e4b248atagen2026-10-10 08:12 UTC193
            .map(|(min, max)| json!({ "min": min, "max": max }));
a9b016e4b248atagen2026-10-10 08:12 UTC194
        json!({
a9b016e4b248atagen2026-10-10 08:12 UTC195
            "name": self.name(),
a9b016e4b248atagen2026-10-10 08:12 UTC196
            "dc": p.dc,
a9b016e4b248atagen2026-10-10 08:12 UTC197
            "lubricant": lubricant,
a9b016e4b248atagen2026-10-10 08:12 UTC198
            "foreskin": foreskin,
a9b016e4b248atagen2026-10-10 08:12 UTC199
            "locked": self == Self::Uli && !forum_read,
a9b016e4b248atagen2026-10-10 08:12 UTC200
        })
a9b016e4b248atagen2026-10-10 08:12 UTC201
    }
a9b016e4b248atagen2026-10-10 08:12 UTC202
}
a9b016e4b248atagen2026-10-10 08:12 UTC203
a9b016e4b248atagen2026-10-10 08:12 UTC204
pub struct Outcome {
a9b016e4b248atagen2026-10-10 08:12 UTC205
    status: StatusCode,
a9b016e4b248atagen2026-10-10 08:12 UTC206
    body: Option<Value>,
a9b016e4b248atagen2026-10-10 08:12 UTC207
    headers: Vec<(HeaderName, String)>,
a9b016e4b248atagen2026-10-10 08:12 UTC208
}
a9b016e4b248atagen2026-10-10 08:12 UTC209
a9b016e4b248atagen2026-10-10 08:12 UTC210
impl Outcome {
a9b016e4b248atagen2026-10-10 08:12 UTC211
    pub fn new(status: StatusCode, body: Option<Value>) -> Self {
a9b016e4b248atagen2026-10-10 08:12 UTC212
        Self {
a9b016e4b248atagen2026-10-10 08:12 UTC213
            status,
a9b016e4b248atagen2026-10-10 08:12 UTC214
            body,
a9b016e4b248atagen2026-10-10 08:12 UTC215
            headers: Vec::new(),
a9b016e4b248atagen2026-10-10 08:12 UTC216
        }
a9b016e4b248atagen2026-10-10 08:12 UTC217
    }
a9b016e4b248atagen2026-10-10 08:12 UTC218
a9b016e4b248atagen2026-10-10 08:12 UTC219
    pub fn header(mut self, name: HeaderName, value: impl Into<String>) -> Self {
a9b016e4b248atagen2026-10-10 08:12 UTC220
        self.headers.push((name, value.into()));
a9b016e4b248atagen2026-10-10 08:12 UTC221
        self
a9b016e4b248atagen2026-10-10 08:12 UTC222
    }
a9b016e4b248atagen2026-10-10 08:12 UTC223
}
a9b016e4b248atagen2026-10-10 08:12 UTC224
a9b016e4b248atagen2026-10-10 08:12 UTC225
impl IntoResponse for Outcome {
a9b016e4b248atagen2026-10-10 08:12 UTC226
    fn into_response(self) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC227
        let mut response = match self.body {
a9b016e4b248atagen2026-10-10 08:12 UTC228
            // RFC 9110 §15.3.7 obliges a 206 to be a byte range of the full representation, so it is.
a9b016e4b248atagen2026-10-10 08:12 UTC229
            Some(body) if self.status == StatusCode::PARTIAL_CONTENT => {
a9b016e4b248atagen2026-10-10 08:12 UTC230
                let full = body.to_string().into_bytes();
a9b016e4b248atagen2026-10-10 08:12 UTC231
                let half = full.len() / 2;
a9b016e4b248atagen2026-10-10 08:12 UTC232
                let range = format!("bytes 0-{}/{}", half - 1, full.len());
a9b016e4b248atagen2026-10-10 08:12 UTC233
                (
a9b016e4b248atagen2026-10-10 08:12 UTC234
                    self.status,
a9b016e4b248atagen2026-10-10 08:12 UTC235
                    [
a9b016e4b248atagen2026-10-10 08:12 UTC236
                        (header::CONTENT_TYPE, "application/json".to_owned()),
a9b016e4b248atagen2026-10-10 08:12 UTC237
                        (header::CONTENT_RANGE, range),
a9b016e4b248atagen2026-10-10 08:12 UTC238
                    ],
a9b016e4b248atagen2026-10-10 08:12 UTC239
                    Body::from(full[..half].to_vec()),
a9b016e4b248atagen2026-10-10 08:12 UTC240
                )
a9b016e4b248atagen2026-10-10 08:12 UTC241
                    .into_response()
a9b016e4b248atagen2026-10-10 08:12 UTC242
            }
a9b016e4b248atagen2026-10-10 08:12 UTC243
            Some(body) => (self.status, Json(body)).into_response(),
a9b016e4b248atagen2026-10-10 08:12 UTC244
            None => self.status.into_response(),
a9b016e4b248atagen2026-10-10 08:12 UTC245
        };
a9b016e4b248atagen2026-10-10 08:12 UTC246
        for (name, value) in self.headers {
a9b016e4b248atagen2026-10-10 08:12 UTC247
            let value = HeaderValue::from_str(&value).expect("header values are visible ascii");
a9b016e4b248atagen2026-10-10 08:12 UTC248
            response.headers_mut().append(name, value);
a9b016e4b248atagen2026-10-10 08:12 UTC249
        }
a9b016e4b248atagen2026-10-10 08:12 UTC250
        response
a9b016e4b248atagen2026-10-10 08:12 UTC251
    }
a9b016e4b248atagen2026-10-10 08:12 UTC252
}
a9b016e4b248atagen2026-10-10 08:12 UTC253
a9b016e4b248atagen2026-10-10 08:12 UTC254
#[derive(Deserialize)]
a9b016e4b248atagen2026-10-10 08:12 UTC255
pub struct PatchOp {
a9b016e4b248atagen2026-10-10 08:12 UTC256
    pub op: String,
a9b016e4b248atagen2026-10-10 08:12 UTC257
    pub path: String,
a9b016e4b248atagen2026-10-10 08:12 UTC258
    #[serde(default)]
a9b016e4b248atagen2026-10-10 08:12 UTC259
    pub value: Value,
a9b016e4b248atagen2026-10-10 08:12 UTC260
}
a9b016e4b248atagen2026-10-10 08:12 UTC261
a9b016e4b248atagen2026-10-10 08:12 UTC262
pub struct Pending {
a9b016e4b248atagen2026-10-10 08:12 UTC263
    due: u32,
a9b016e4b248atagen2026-10-10 08:12 UTC264
    length: f64,
a9b016e4b248atagen2026-10-10 08:12 UTC265
    girth: f64,
a9b016e4b248atagen2026-10-10 08:12 UTC266
}
a9b016e4b248atagen2026-10-10 08:12 UTC267
a9b016e4b248atagen2026-10-10 08:12 UTC268
pub struct Ending {
a9b016e4b248atagen2026-10-10 08:12 UTC269
    pub cause: &'static str,
a9b016e4b248atagen2026-10-10 08:12 UTC270
    pub detail: String,
a9b016e4b248atagen2026-10-10 08:12 UTC271
    pub turn: u32,
a9b016e4b248atagen2026-10-10 08:12 UTC272
}
a9b016e4b248atagen2026-10-10 08:12 UTC273
a9b016e4b248atagen2026-10-10 08:12 UTC274
pub struct Run {
a9b016e4b248atagen2026-10-10 08:12 UTC275
    pub id: String,
a9b016e4b248atagen2026-10-10 08:12 UTC276
    pub origin_etag: String,
a9b016e4b248atagen2026-10-10 08:12 UTC277
    pub version: u64,
a9b016e4b248atagen2026-10-10 08:12 UTC278
    pub turn: u32,
a9b016e4b248atagen2026-10-10 08:12 UTC279
    pub length: f64,
a9b016e4b248atagen2026-10-10 08:12 UTC280
    pub girth: f64,
a9b016e4b248atagen2026-10-10 08:12 UTC281
    pub foreskin: Option<f64>,
a9b016e4b248atagen2026-10-10 08:12 UTC282
    pub eq: i32,
a9b016e4b248atagen2026-10-10 08:12 UTC283
    pub stamina: i32,
a9b016e4b248atagen2026-10-10 08:12 UTC284
    pub suspicion: u8,
a9b016e4b248atagen2026-10-10 08:12 UTC285
    pub shame_base: u32,
a9b016e4b248atagen2026-10-10 08:12 UTC286
    pub shame: u32,
a9b016e4b248atagen2026-10-10 08:12 UTC287
    pub ice_packs: u32,
a9b016e4b248atagen2026-10-10 08:12 UTC288
    pub warmed_up: bool,
a9b016e4b248atagen2026-10-10 08:12 UTC289
    pub teapot: bool,
a9b016e4b248atagen2026-10-10 08:12 UTC290
    pub ruler: bool,
a9b016e4b248atagen2026-10-10 08:12 UTC291
    pub put_applied: bool,
a9b016e4b248atagen2026-10-10 08:12 UTC292
    pub forum_read: bool,
a9b016e4b248atagen2026-10-10 08:12 UTC293
    pub branch_pending: bool,
a9b016e4b248atagen2026-10-10 08:12 UTC294
    pub mom_present: bool,
a9b016e4b248atagen2026-10-10 08:12 UTC295
    pub observed: bool,
a9b016e4b248atagen2026-10-10 08:12 UTC296
    pub measured: bool,
a9b016e4b248atagen2026-10-10 08:12 UTC297
    pub cured: bool,
a9b016e4b248atagen2026-10-10 08:12 UTC298
    pub intake_attempts: usize,
a9b016e4b248atagen2026-10-10 08:12 UTC299
    pub refractory_until: Option<Instant>,
a9b016e4b248atagen2026-10-10 08:12 UTC300
    pub pending: Vec<Pending>,
a9b016e4b248atagen2026-10-10 08:12 UTC301
    pub ended: Option<Ending>,
a9b016e4b248atagen2026-10-10 08:12 UTC302
    rng: StdRng,
a9b016e4b248atagen2026-10-10 08:12 UTC303
}
a9b016e4b248atagen2026-10-10 08:12 UTC304
a9b016e4b248atagen2026-10-10 08:12 UTC305
fn round2(x: f64) -> f64 {
a9b016e4b248atagen2026-10-10 08:12 UTC306
    (x * 100.0).round() / 100.0
a9b016e4b248atagen2026-10-10 08:12 UTC307
}
a9b016e4b248atagen2026-10-10 08:12 UTC308
a9b016e4b248atagen2026-10-10 08:12 UTC309
fn round3(x: f64) -> f64 {
a9b016e4b248atagen2026-10-10 08:12 UTC310
    (x * 1000.0).round() / 1000.0
a9b016e4b248atagen2026-10-10 08:12 UTC311
}
a9b016e4b248atagen2026-10-10 08:12 UTC312
a9b016e4b248atagen2026-10-10 08:12 UTC313
fn tenths(x: f64) -> i64 {
a9b016e4b248atagen2026-10-10 08:12 UTC314
    (x * 10.0).round() as i64
a9b016e4b248atagen2026-10-10 08:12 UTC315
}
a9b016e4b248atagen2026-10-10 08:12 UTC316
a9b016e4b248atagen2026-10-10 08:12 UTC317
fn not_extended() -> Problem {
a9b016e4b248atagen2026-10-10 08:12 UTC318
    Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC319
        StatusCode::NOT_EXTENDED,
a9b016e4b248atagen2026-10-10 08:12 UTC320
        "not-extended",
a9b016e4b248atagen2026-10-10 08:12 UTC321
        "The requested extension was not applied. The server has recorded the attempt.",
a9b016e4b248atagen2026-10-10 08:12 UTC322
    )
a9b016e4b248atagen2026-10-10 08:12 UTC323
}
a9b016e4b248atagen2026-10-10 08:12 UTC324
a9b016e4b248atagen2026-10-10 08:12 UTC325
pub fn parse_credentials<'a>(authorization: &'a str, scheme: &str) -> Option<&'a str> {
a9b016e4b248atagen2026-10-10 08:12 UTC326
    let (given, rest) = authorization.trim().split_once(' ')?;
a9b016e4b248atagen2026-10-10 08:12 UTC327
    given
a9b016e4b248atagen2026-10-10 08:12 UTC328
        .eq_ignore_ascii_case(scheme)
a9b016e4b248atagen2026-10-10 08:12 UTC329
        .then(|| rest.trim().trim_matches('"'))
a9b016e4b248atagen2026-10-10 08:12 UTC330
}
a9b016e4b248atagen2026-10-10 08:12 UTC331
a9b016e4b248atagen2026-10-10 08:12 UTC332
impl Run {
a9b016e4b248atagen2026-10-10 08:12 UTC333
    pub fn new(id: String, seed: u64, shame_base: u32) -> Self {
a9b016e4b248atagen2026-10-10 08:12 UTC334
        let mut rng = StdRng::seed_from_u64(seed);
a9b016e4b248atagen2026-10-10 08:12 UTC335
        let length = round2(rng.random_range(4.5..6.5));
a9b016e4b248atagen2026-10-10 08:12 UTC336
        let girth = round2(rng.random_range(4.0..5.0));
a9b016e4b248atagen2026-10-10 08:12 UTC337
        let foreskin = round2(rng.random_range(0.4..2.4));
a9b016e4b248atagen2026-10-10 08:12 UTC338
        let mut run = Self {
a9b016e4b248atagen2026-10-10 08:12 UTC339
            id,
a9b016e4b248atagen2026-10-10 08:12 UTC340
            origin_etag: String::new(),
a9b016e4b248atagen2026-10-10 08:12 UTC341
            version: 0,
a9b016e4b248atagen2026-10-10 08:12 UTC342
            turn: 0,
a9b016e4b248atagen2026-10-10 08:12 UTC343
            length,
a9b016e4b248atagen2026-10-10 08:12 UTC344
            girth,
a9b016e4b248atagen2026-10-10 08:12 UTC345
            foreskin: Some(foreskin),
a9b016e4b248atagen2026-10-10 08:12 UTC346
            eq: 20,
a9b016e4b248atagen2026-10-10 08:12 UTC347
            stamina: MAX_STAMINA,
a9b016e4b248atagen2026-10-10 08:12 UTC348
            suspicion: 0,
a9b016e4b248atagen2026-10-10 08:12 UTC349
            shame_base,
a9b016e4b248atagen2026-10-10 08:12 UTC350
            shame: 0,
a9b016e4b248atagen2026-10-10 08:12 UTC351
            ice_packs: 1,
a9b016e4b248atagen2026-10-10 08:12 UTC352
            warmed_up: false,
a9b016e4b248atagen2026-10-10 08:12 UTC353
            teapot: false,
a9b016e4b248atagen2026-10-10 08:12 UTC354
            ruler: false,
a9b016e4b248atagen2026-10-10 08:12 UTC355
            put_applied: false,
a9b016e4b248atagen2026-10-10 08:12 UTC356
            forum_read: false,
a9b016e4b248atagen2026-10-10 08:12 UTC357
            branch_pending: false,
a9b016e4b248atagen2026-10-10 08:12 UTC358
            mom_present: false,
a9b016e4b248atagen2026-10-10 08:12 UTC359
            observed: false,
a9b016e4b248atagen2026-10-10 08:12 UTC360
            measured: false,
a9b016e4b248atagen2026-10-10 08:12 UTC361
            cured: false,
a9b016e4b248atagen2026-10-10 08:12 UTC362
            intake_attempts: 0,
a9b016e4b248atagen2026-10-10 08:12 UTC363
            refractory_until: None,
a9b016e4b248atagen2026-10-10 08:12 UTC364
            pending: Vec::new(),
a9b016e4b248atagen2026-10-10 08:12 UTC365
            ended: None,
a9b016e4b248atagen2026-10-10 08:12 UTC366
            rng,
a9b016e4b248atagen2026-10-10 08:12 UTC367
        };
a9b016e4b248atagen2026-10-10 08:12 UTC368
        run.origin_etag = run.etag();
a9b016e4b248atagen2026-10-10 08:12 UTC369
        run
a9b016e4b248atagen2026-10-10 08:12 UTC370
    }
a9b016e4b248atagen2026-10-10 08:12 UTC371
a9b016e4b248atagen2026-10-10 08:12 UTC372
    pub fn etag(&self) -> String {
a9b016e4b248atagen2026-10-10 08:12 UTC373
        format!("\"{}-{}\"", self.id, self.version)
a9b016e4b248atagen2026-10-10 08:12 UTC374
    }
a9b016e4b248atagen2026-10-10 08:12 UTC375
a9b016e4b248atagen2026-10-10 08:12 UTC376
    pub fn touch(&mut self) {
a9b016e4b248atagen2026-10-10 08:12 UTC377
        self.version += 1;
a9b016e4b248atagen2026-10-10 08:12 UTC378
    }
a9b016e4b248atagen2026-10-10 08:12 UTC379
a9b016e4b248atagen2026-10-10 08:12 UTC380
    pub fn self_reported(&self) -> f64 {
a9b016e4b248atagen2026-10-10 08:12 UTC381
        self.length + SELF_REPORT_INFLATION
a9b016e4b248atagen2026-10-10 08:12 UTC382
    }
a9b016e4b248atagen2026-10-10 08:12 UTC383
a9b016e4b248atagen2026-10-10 08:12 UTC384
    pub fn total_shame(&self) -> u32 {
a9b016e4b248atagen2026-10-10 08:12 UTC385
        self.shame_base + self.shame
a9b016e4b248atagen2026-10-10 08:12 UTC386
    }
a9b016e4b248atagen2026-10-10 08:12 UTC387
a9b016e4b248atagen2026-10-10 08:12 UTC388
    pub fn status(&self) -> &'static str {
a9b016e4b248atagen2026-10-10 08:12 UTC389
        match (&self.ended, self.cured) {
a9b016e4b248atagen2026-10-10 08:12 UTC390
            (Some(_), _) => "gone",
a9b016e4b248atagen2026-10-10 08:12 UTC391
            (None, true) => "normal",
a9b016e4b248atagen2026-10-10 08:12 UTC392
            (None, false) => "alive",
a9b016e4b248atagen2026-10-10 08:12 UTC393
        }
a9b016e4b248atagen2026-10-10 08:12 UTC394
    }
a9b016e4b248atagen2026-10-10 08:12 UTC395
a9b016e4b248atagen2026-10-10 08:12 UTC396
    pub fn view(&self) -> Value {
a9b016e4b248atagen2026-10-10 08:12 UTC397
        let mut view = json!({
a9b016e4b248atagen2026-10-10 08:12 UTC398
            "id": self.id,
a9b016e4b248atagen2026-10-10 08:12 UTC399
            "status": self.status(),
a9b016e4b248atagen2026-10-10 08:12 UTC400
            "turn": self.turn,
a9b016e4b248atagen2026-10-10 08:12 UTC401
            "floor": 1,
a9b016e4b248atagen2026-10-10 08:12 UTC402
            "self_reported_length": round2(self.self_reported()),
a9b016e4b248atagen2026-10-10 08:12 UTC403
            "girth": round2(self.girth),
a9b016e4b248atagen2026-10-10 08:12 UTC404
            "foreskin": self.foreskin.map(round2),
a9b016e4b248atagen2026-10-10 08:12 UTC405
            "eq": self.eq,
a9b016e4b248atagen2026-10-10 08:12 UTC406
            "stamina": self.stamina,
a9b016e4b248atagen2026-10-10 08:12 UTC407
            "suspicion": self.suspicion,
a9b016e4b248atagen2026-10-10 08:12 UTC408
            "shame": self.total_shame(),
a9b016e4b248atagen2026-10-10 08:12 UTC409
            "warmed_up": self.warmed_up,
a9b016e4b248atagen2026-10-10 08:12 UTC410
        });
a9b016e4b248atagen2026-10-10 08:12 UTC411
        if self.ruler {
a9b016e4b248atagen2026-10-10 08:12 UTC412
            view["length"] = json!(round2(self.length));
a9b016e4b248atagen2026-10-10 08:12 UTC413
        }
a9b016e4b248atagen2026-10-10 08:12 UTC414
        if self.teapot {
a9b016e4b248atagen2026-10-10 08:12 UTC415
            view["condition"] = json!("short and stout");
a9b016e4b248atagen2026-10-10 08:12 UTC416
        }
a9b016e4b248atagen2026-10-10 08:12 UTC417
        view
a9b016e4b248atagen2026-10-10 08:12 UTC418
    }
a9b016e4b248atagen2026-10-10 08:12 UTC419
a9b016e4b248atagen2026-10-10 08:12 UTC420
    pub fn end(&mut self, cause: &'static str, detail: &str) {
a9b016e4b248atagen2026-10-10 08:12 UTC421
        self.ended = Some(Ending {
a9b016e4b248atagen2026-10-10 08:12 UTC422
            cause,
a9b016e4b248atagen2026-10-10 08:12 UTC423
            detail: detail.into(),
a9b016e4b248atagen2026-10-10 08:12 UTC424
            turn: self.turn,
a9b016e4b248atagen2026-10-10 08:12 UTC425
        });
a9b016e4b248atagen2026-10-10 08:12 UTC426
    }
a9b016e4b248atagen2026-10-10 08:12 UTC427
a9b016e4b248atagen2026-10-10 08:12 UTC428
    pub fn answer_door(&mut self, authorization: Option<&str>) -> Result<(), Problem> {
a9b016e4b248atagen2026-10-10 08:12 UTC429
        let challenge =
a9b016e4b248atagen2026-10-10 08:12 UTC430
            |p: Problem| p.header(header::WWW_AUTHENTICATE, "Excuse realm=\"household\"");
a9b016e4b248atagen2026-10-10 08:12 UTC431
        let Some(authorization) = authorization else {
a9b016e4b248atagen2026-10-10 08:12 UTC432
            return Err(challenge(
a9b016e4b248atagen2026-10-10 08:12 UTC433
                Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC434
                    StatusCode::UNAUTHORIZED,
a9b016e4b248atagen2026-10-10 08:12 UTC435
                    "someone-at-the-door",
a9b016e4b248atagen2026-10-10 08:12 UTC436
                    "Someone is at the door.",
a9b016e4b248atagen2026-10-10 08:12 UTC437
                )
a9b016e4b248atagen2026-10-10 08:12 UTC438
                .with("inventory", format!("/runs/{}/inventory", self.id)),
a9b016e4b248atagen2026-10-10 08:12 UTC439
            ));
a9b016e4b248atagen2026-10-10 08:12 UTC440
        };
a9b016e4b248atagen2026-10-10 08:12 UTC441
        let excuse = parse_credentials(authorization, "excuse");
a9b016e4b248atagen2026-10-10 08:12 UTC442
        if excuse.is_some_and(|e| EXCUSES.iter().any(|known| known.eq_ignore_ascii_case(e))) {
a9b016e4b248atagen2026-10-10 08:12 UTC443
            self.mom_present = false;
a9b016e4b248atagen2026-10-10 08:12 UTC444
            self.suspicion = 0;
a9b016e4b248atagen2026-10-10 08:12 UTC445
            self.touch();
a9b016e4b248atagen2026-10-10 08:12 UTC446
            return Ok(());
a9b016e4b248atagen2026-10-10 08:12 UTC447
        }
a9b016e4b248atagen2026-10-10 08:12 UTC448
        self.shame += 3;
a9b016e4b248atagen2026-10-10 08:12 UTC449
        self.touch();
a9b016e4b248atagen2026-10-10 08:12 UTC450
        Err(challenge(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC451
            StatusCode::UNAUTHORIZED,
a9b016e4b248atagen2026-10-10 08:12 UTC452
            "excuse-rejected",
a9b016e4b248atagen2026-10-10 08:12 UTC453
            "Mom has not heard that one before. She does not believe it.",
a9b016e4b248atagen2026-10-10 08:12 UTC454
        )))
a9b016e4b248atagen2026-10-10 08:12 UTC455
    }
a9b016e4b248atagen2026-10-10 08:12 UTC456
a9b016e4b248atagen2026-10-10 08:12 UTC457
    fn check_refractory(&mut self, now: Instant) -> Result<(), Problem> {
a9b016e4b248atagen2026-10-10 08:12 UTC458
        let Some(until) = self.refractory_until else {
a9b016e4b248atagen2026-10-10 08:12 UTC459
            return Ok(());
a9b016e4b248atagen2026-10-10 08:12 UTC460
        };
a9b016e4b248atagen2026-10-10 08:12 UTC461
        if now < until {
a9b016e4b248atagen2026-10-10 08:12 UTC462
            let seconds = (until - now).as_secs() + 1;
a9b016e4b248atagen2026-10-10 08:12 UTC463
            return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC464
                StatusCode::TOO_MANY_REQUESTS,
a9b016e4b248atagen2026-10-10 08:12 UTC465
                "overtrained",
a9b016e4b248atagen2026-10-10 08:12 UTC466
                "Stamina is exhausted. The refractory period is in effect.",
a9b016e4b248atagen2026-10-10 08:12 UTC467
            )
a9b016e4b248atagen2026-10-10 08:12 UTC468
            .header(header::RETRY_AFTER, seconds.to_string()));
a9b016e4b248atagen2026-10-10 08:12 UTC469
        }
a9b016e4b248atagen2026-10-10 08:12 UTC470
        self.refractory_until = None;
a9b016e4b248atagen2026-10-10 08:12 UTC471
        self.stamina = MAX_STAMINA;
a9b016e4b248atagen2026-10-10 08:12 UTC472
        self.eq = 20;
a9b016e4b248atagen2026-10-10 08:12 UTC473
        self.warmed_up = false;
a9b016e4b248atagen2026-10-10 08:12 UTC474
        self.touch();
a9b016e4b248atagen2026-10-10 08:12 UTC475
        Ok(())
a9b016e4b248atagen2026-10-10 08:12 UTC476
    }
a9b016e4b248atagen2026-10-10 08:12 UTC477
a9b016e4b248atagen2026-10-10 08:12 UTC478
    fn check_foreskin(&self, min: f64, max: f64) -> Result<(), Problem> {
a9b016e4b248atagen2026-10-10 08:12 UTC479
        let Some(f) = self.foreskin else {
a9b016e4b248atagen2026-10-10 08:12 UTC480
            if min <= 0.0 {
a9b016e4b248atagen2026-10-10 08:12 UTC481
                return Ok(());
a9b016e4b248atagen2026-10-10 08:12 UTC482
            }
a9b016e4b248atagen2026-10-10 08:12 UTC483
            return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC484
                StatusCode::GONE,
a9b016e4b248atagen2026-10-10 08:12 UTC485
                "foreskin-gone",
a9b016e4b248atagen2026-10-10 08:12 UTC486
                "This technique requires a foreskin. The foreskin is gone.",
a9b016e4b248atagen2026-10-10 08:12 UTC487
            )
a9b016e4b248atagen2026-10-10 08:12 UTC488
            .header(
a9b016e4b248atagen2026-10-10 08:12 UTC489
                header::CONTENT_LOCATION,
a9b016e4b248atagen2026-10-10 08:12 UTC490
                format!("/runs/{}/foreskin", self.id),
a9b016e4b248atagen2026-10-10 08:12 UTC491
            ));
a9b016e4b248atagen2026-10-10 08:12 UTC492
        };
a9b016e4b248atagen2026-10-10 08:12 UTC493
        if f < min {
a9b016e4b248atagen2026-10-10 08:12 UTC494
            return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC495
                StatusCode::LENGTH_REQUIRED,
a9b016e4b248atagen2026-10-10 08:12 UTC496
                "foreskin-insufficient",
a9b016e4b248atagen2026-10-10 08:12 UTC497
                format!(
a9b016e4b248atagen2026-10-10 08:12 UTC498
                    "The selected technique requires {min:.1} units of foreskin. {f:.2} units are available. The server has noted the shortfall."
a9b016e4b248atagen2026-10-10 08:12 UTC499
                ),
a9b016e4b248atagen2026-10-10 08:12 UTC500
            )
a9b016e4b248atagen2026-10-10 08:12 UTC501
            .with("required", min)
a9b016e4b248atagen2026-10-10 08:12 UTC502
            .with("available", round2(f)));
a9b016e4b248atagen2026-10-10 08:12 UTC503
        }
a9b016e4b248atagen2026-10-10 08:12 UTC504
        if f > max {
a9b016e4b248atagen2026-10-10 08:12 UTC505
            return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC506
                StatusCode::PAYLOAD_TOO_LARGE,
a9b016e4b248atagen2026-10-10 08:12 UTC507
                "foreskin-excessive",
a9b016e4b248atagen2026-10-10 08:12 UTC508
                format!(
a9b016e4b248atagen2026-10-10 08:12 UTC509
                    "The selected technique tolerates at most {max:.1} units of foreskin. {f:.2} units are present."
a9b016e4b248atagen2026-10-10 08:12 UTC510
                ),
a9b016e4b248atagen2026-10-10 08:12 UTC511
            )
a9b016e4b248atagen2026-10-10 08:12 UTC512
            .with("maximum", max)
a9b016e4b248atagen2026-10-10 08:12 UTC513
            .with("present", round2(f)));
a9b016e4b248atagen2026-10-10 08:12 UTC514
        }
a9b016e4b248atagen2026-10-10 08:12 UTC515
        Ok(())
a9b016e4b248atagen2026-10-10 08:12 UTC516
    }
a9b016e4b248atagen2026-10-10 08:12 UTC517
a9b016e4b248atagen2026-10-10 08:12 UTC518
    fn realize(&mut self) {
a9b016e4b248atagen2026-10-10 08:12 UTC519
        let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)
a9b016e4b248atagen2026-10-10 08:12 UTC520
            .into_iter()
a9b016e4b248atagen2026-10-10 08:12 UTC521
            .partition(|p| p.due <= self.turn);
a9b016e4b248atagen2026-10-10 08:12 UTC522
        self.pending = waiting;
a9b016e4b248atagen2026-10-10 08:12 UTC523
        for p in due {
a9b016e4b248atagen2026-10-10 08:12 UTC524
            self.length += p.length;
a9b016e4b248atagen2026-10-10 08:12 UTC525
            self.girth += p.girth;
a9b016e4b248atagen2026-10-10 08:12 UTC526
        }
a9b016e4b248atagen2026-10-10 08:12 UTC527
    }
a9b016e4b248atagen2026-10-10 08:12 UTC528
a9b016e4b248atagen2026-10-10 08:12 UTC529
    fn gain(&mut self, length: f64, girth: f64, foreskin: f64) {
a9b016e4b248atagen2026-10-10 08:12 UTC530
        self.length += length;
a9b016e4b248atagen2026-10-10 08:12 UTC531
        self.girth += girth;
a9b016e4b248atagen2026-10-10 08:12 UTC532
        if let Some(f) = &mut self.foreskin {
a9b016e4b248atagen2026-10-10 08:12 UTC533
            *f += foreskin;
a9b016e4b248atagen2026-10-10 08:12 UTC534
        }
a9b016e4b248atagen2026-10-10 08:12 UTC535
    }
a9b016e4b248atagen2026-10-10 08:12 UTC536
a9b016e4b248atagen2026-10-10 08:12 UTC537
    fn rupture(&mut self) -> Problem {
a9b016e4b248atagen2026-10-10 08:12 UTC538
        let detail = if self.ice_packs > 0 {
a9b016e4b248atagen2026-10-10 08:12 UTC539
            "A vessel has exceeded its rated pressure. The run has ended. An ice pack was available and was not used."
a9b016e4b248atagen2026-10-10 08:12 UTC540
        } else {
a9b016e4b248atagen2026-10-10 08:12 UTC541
            "A vessel has exceeded its rated pressure. The run has ended."
a9b016e4b248atagen2026-10-10 08:12 UTC542
        };
a9b016e4b248atagen2026-10-10 08:12 UTC543
        self.end("vascular-event", detail);
a9b016e4b248atagen2026-10-10 08:12 UTC544
        Problem::new(StatusCode::INTERNAL_SERVER_ERROR, "vascular-event", detail)
a9b016e4b248atagen2026-10-10 08:12 UTC545
            .with("eq_at_failure", self.eq)
a9b016e4b248atagen2026-10-10 08:12 UTC546
            .with("turn", self.turn)
a9b016e4b248atagen2026-10-10 08:12 UTC547
    }
a9b016e4b248atagen2026-10-10 08:12 UTC548
a9b016e4b248atagen2026-10-10 08:12 UTC549
    pub fn jelq(
a9b016e4b248atagen2026-10-10 08:12 UTC550
        &mut self,
a9b016e4b248atagen2026-10-10 08:12 UTC551
        technique: Technique,
a9b016e4b248atagen2026-10-10 08:12 UTC552
        intensity: i32,
a9b016e4b248atagen2026-10-10 08:12 UTC553
        lube: Option<Lube>,
a9b016e4b248atagen2026-10-10 08:12 UTC554
        now: Instant,
a9b016e4b248atagen2026-10-10 08:12 UTC555
    ) -> Result<Outcome, Problem> {
a9b016e4b248atagen2026-10-10 08:12 UTC556
        self.check_refractory(now)?;
a9b016e4b248atagen2026-10-10 08:12 UTC557
        if self.branch_pending {
a9b016e4b248atagen2026-10-10 08:12 UTC558
            return Ok(self.branch_offer());
a9b016e4b248atagen2026-10-10 08:12 UTC559
        }
a9b016e4b248atagen2026-10-10 08:12 UTC560
        let Some(profile) = technique.profile() else {
a9b016e4b248atagen2026-10-10 08:12 UTC561
            return self.utility(technique);
a9b016e4b248atagen2026-10-10 08:12 UTC562
        };
a9b016e4b248atagen2026-10-10 08:12 UTC563
        if technique == Technique::Uli && !self.forum_read {
a9b016e4b248atagen2026-10-10 08:12 UTC564
            return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC565
                StatusCode::FORBIDDEN,
a9b016e4b248atagen2026-10-10 08:12 UTC566
                "technique-locked",
a9b016e4b248atagen2026-10-10 08:12 UTC567
                "This technique is discussed on the forum. You have not been to the forum.",
a9b016e4b248atagen2026-10-10 08:12 UTC568
            ));
a9b016e4b248atagen2026-10-10 08:12 UTC569
        }
a9b016e4b248atagen2026-10-10 08:12 UTC570
        if !self.warmed_up {
a9b016e4b248atagen2026-10-10 08:12 UTC571
            return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC572
                StatusCode::PRECONDITION_FAILED,
a9b016e4b248atagen2026-10-10 08:12 UTC573
                "cold-start",
a9b016e4b248atagen2026-10-10 08:12 UTC574
                "Warm-up was skipped. The server has noted this.",
a9b016e4b248atagen2026-10-10 08:12 UTC575
            )
a9b016e4b248atagen2026-10-10 08:12 UTC576
            .with("technique", Technique::Warmup.name()));
a9b016e4b248atagen2026-10-10 08:12 UTC577
        }
a9b016e4b248atagen2026-10-10 08:12 UTC578
        match (profile.lube, lube) {
a9b016e4b248atagen2026-10-10 08:12 UTC579
            (LubeRule::Required, None) => {
a9b016e4b248atagen2026-10-10 08:12 UTC580
                return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC581
                    StatusCode::UNSUPPORTED_MEDIA_TYPE,
a9b016e4b248atagen2026-10-10 08:12 UTC582
                    "lubricant-required",
a9b016e4b248atagen2026-10-10 08:12 UTC583
                    format!(
a9b016e4b248atagen2026-10-10 08:12 UTC584
                        "The {} technique requires a lubricant. None was declared.",
a9b016e4b248atagen2026-10-10 08:12 UTC585
                        technique.name()
a9b016e4b248atagen2026-10-10 08:12 UTC586
                    ),
a9b016e4b248atagen2026-10-10 08:12 UTC587
                )
a9b016e4b248atagen2026-10-10 08:12 UTC588
                .with("lubricants", json!(LUBRICANTS)));
a9b016e4b248atagen2026-10-10 08:12 UTC589
            }
a9b016e4b248atagen2026-10-10 08:12 UTC590
            (LubeRule::Forbidden, Some(_)) => {
a9b016e4b248atagen2026-10-10 08:12 UTC591
                return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC592
                    StatusCode::UNSUPPORTED_MEDIA_TYPE,
a9b016e4b248atagen2026-10-10 08:12 UTC593
                    "lubricant-declared",
a9b016e4b248atagen2026-10-10 08:12 UTC594
                    "The dry technique was requested. A lubricant was declared.",
a9b016e4b248atagen2026-10-10 08:12 UTC595
                ));
a9b016e4b248atagen2026-10-10 08:12 UTC596
            }
a9b016e4b248atagen2026-10-10 08:12 UTC597
            _ => {}
a9b016e4b248atagen2026-10-10 08:12 UTC598
        }
a9b016e4b248atagen2026-10-10 08:12 UTC599
        if let Some((min, max)) = profile.foreskin {
a9b016e4b248atagen2026-10-10 08:12 UTC600
            self.check_foreskin(min, max)?;
a9b016e4b248atagen2026-10-10 08:12 UTC601
        }
a9b016e4b248atagen2026-10-10 08:12 UTC602
a9b016e4b248atagen2026-10-10 08:12 UTC603
        self.touch();
a9b016e4b248atagen2026-10-10 08:12 UTC604
        self.turn += 1;
a9b016e4b248atagen2026-10-10 08:12 UTC605
        self.realize();
a9b016e4b248atagen2026-10-10 08:12 UTC606
        self.stamina -= 1 + i32::from(lube == Some(Lube::Lotion));
a9b016e4b248atagen2026-10-10 08:12 UTC607
        if self.stamina <= 0 {
a9b016e4b248atagen2026-10-10 08:12 UTC608
            self.stamina = 0;
a9b016e4b248atagen2026-10-10 08:12 UTC609
            self.refractory_until = Some(now + REFRACTORY);
a9b016e4b248atagen2026-10-10 08:12 UTC610
        }
a9b016e4b248atagen2026-10-10 08:12 UTC611
        self.eq = (self.eq + intensity * 2 - 6).clamp(0, 100);
a9b016e4b248atagen2026-10-10 08:12 UTC612
        let suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));
a9b016e4b248atagen2026-10-10 08:12 UTC613
        self.suspicion = (self.suspicion + suspicion as u8).min(SUSPICION_LIMIT);
a9b016e4b248atagen2026-10-10 08:12 UTC614
        if self.suspicion == SUSPICION_LIMIT {
a9b016e4b248atagen2026-10-10 08:12 UTC615
            self.mom_present = true;
a9b016e4b248atagen2026-10-10 08:12 UTC616
        }
a9b016e4b248atagen2026-10-10 08:12 UTC617
        if self.rng.random_ratio(1, 6) {
a9b016e4b248atagen2026-10-10 08:12 UTC618
            self.observed = true;
a9b016e4b248atagen2026-10-10 08:12 UTC619
            self.shame += 1;
a9b016e4b248atagen2026-10-10 08:12 UTC620
        }
a9b016e4b248atagen2026-10-10 08:12 UTC621
        if self.turn == BRANCH_TURN {
a9b016e4b248atagen2026-10-10 08:12 UTC622
            self.branch_pending = true;
a9b016e4b248atagen2026-10-10 08:12 UTC623
        }
a9b016e4b248atagen2026-10-10 08:12 UTC624
a9b016e4b248atagen2026-10-10 08:12 UTC625
        if self.eq > 95 {
a9b016e4b248atagen2026-10-10 08:12 UTC626
            self.eq = 0;
a9b016e4b248atagen2026-10-10 08:12 UTC627
            self.warmed_up = false;
a9b016e4b248atagen2026-10-10 08:12 UTC628
            self.shame += 5;
a9b016e4b248atagen2026-10-10 08:12 UTC629
            return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC630
                StatusCode::TOO_EARLY,
a9b016e4b248atagen2026-10-10 08:12 UTC631
                "premature",
a9b016e4b248atagen2026-10-10 08:12 UTC632
                "The floor has ended early. Everyone involved has agreed not to discuss it.",
a9b016e4b248atagen2026-10-10 08:12 UTC633
            ));
a9b016e4b248atagen2026-10-10 08:12 UTC634
        }
a9b016e4b248atagen2026-10-10 08:12 UTC635
        if self.eq < 30 {
a9b016e4b248atagen2026-10-10 08:12 UTC636
            return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC637
                StatusCode::NOT_FOUND,
a9b016e4b248atagen2026-10-10 08:12 UTC638
                "cold",
a9b016e4b248atagen2026-10-10 08:12 UTC639
                "The resource exists. It is cold.",
a9b016e4b248atagen2026-10-10 08:12 UTC640
            )
a9b016e4b248atagen2026-10-10 08:12 UTC641
            .with("eq", self.eq));
a9b016e4b248atagen2026-10-10 08:12 UTC642
        }
a9b016e4b248atagen2026-10-10 08:12 UTC643
        self.roll(technique, &profile, intensity, lube)
a9b016e4b248atagen2026-10-10 08:12 UTC644
    }
a9b016e4b248atagen2026-10-10 08:12 UTC645
a9b016e4b248atagen2026-10-10 08:12 UTC646
    fn roll(
a9b016e4b248atagen2026-10-10 08:12 UTC647
        &mut self,
a9b016e4b248atagen2026-10-10 08:12 UTC648
        technique: Technique,
a9b016e4b248atagen2026-10-10 08:12 UTC649
        profile: &Profile,
a9b016e4b248atagen2026-10-10 08:12 UTC650
        intensity: i32,
a9b016e4b248atagen2026-10-10 08:12 UTC651
        lube: Option<Lube>,
a9b016e4b248atagen2026-10-10 08:12 UTC652
    ) -> Result<Outcome, Problem> {
a9b016e4b248atagen2026-10-10 08:12 UTC653
        let natural: i32 = self.rng.random_range(1..=20);
a9b016e4b248atagen2026-10-10 08:12 UTC654
        let band = if (50..=70).contains(&self.eq) { 2 } else { 0 };
a9b016e4b248atagen2026-10-10 08:12 UTC655
        let modifier = lube.map_or(0, Lube::modifier) + band;
a9b016e4b248atagen2026-10-10 08:12 UTC656
        let dc = profile.dc + intensity / 3;
a9b016e4b248atagen2026-10-10 08:12 UTC657
        let total = natural + modifier;
a9b016e4b248atagen2026-10-10 08:12 UTC658
        let timing = format!("roll;desc=\"d20{modifier:+} vs {dc}\";dur={natural}");
a9b016e4b248atagen2026-10-10 08:12 UTC659
        let timed = |p: Problem| p.header(SERVER_TIMING, &timing);
a9b016e4b248atagen2026-10-10 08:12 UTC660
a9b016e4b248atagen2026-10-10 08:12 UTC661
        let scale = f64::from(intensity);
a9b016e4b248atagen2026-10-10 08:12 UTC662
        let (mut length, mut girth) = (profile.length * scale, profile.girth * scale);
a9b016e4b248atagen2026-10-10 08:12 UTC663
        if self.teapot {
a9b016e4b248atagen2026-10-10 08:12 UTC664
            length *= 0.5;
a9b016e4b248atagen2026-10-10 08:12 UTC665
            girth *= 1.5;
a9b016e4b248atagen2026-10-10 08:12 UTC666
        }
a9b016e4b248atagen2026-10-10 08:12 UTC667
a9b016e4b248atagen2026-10-10 08:12 UTC668
        if natural == 1 {
a9b016e4b248atagen2026-10-10 08:12 UTC669
            let touches_foreskin = profile.foreskin.is_some_and(|(min, _)| min > 0.0);
a9b016e4b248atagen2026-10-10 08:12 UTC670
            if touches_foreskin && self.foreskin.is_some() {
a9b016e4b248atagen2026-10-10 08:12 UTC671
                self.foreskin = None;
a9b016e4b248atagen2026-10-10 08:12 UTC672
                self.shame += 25;
a9b016e4b248atagen2026-10-10 08:12 UTC673
                return Err(timed(
a9b016e4b248atagen2026-10-10 08:12 UTC674
                    Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC675
                        StatusCode::GONE,
a9b016e4b248atagen2026-10-10 08:12 UTC676
                        "foreskin-lost",
a9b016e4b248atagen2026-10-10 08:12 UTC677
                        "The technique has removed the foreskin. It will not be returning.",
a9b016e4b248atagen2026-10-10 08:12 UTC678
                    )
a9b016e4b248atagen2026-10-10 08:12 UTC679
                    .header(
a9b016e4b248atagen2026-10-10 08:12 UTC680
                        header::CONTENT_LOCATION,
a9b016e4b248atagen2026-10-10 08:12 UTC681
                        format!("/runs/{}/foreskin", self.id),
a9b016e4b248atagen2026-10-10 08:12 UTC682
                    ),
a9b016e4b248atagen2026-10-10 08:12 UTC683
                ));
a9b016e4b248atagen2026-10-10 08:12 UTC684
            }
a9b016e4b248atagen2026-10-10 08:12 UTC685
            if self.eq > 70 {
a9b016e4b248atagen2026-10-10 08:12 UTC686
                return Err(timed(self.rupture()));
a9b016e4b248atagen2026-10-10 08:12 UTC687
            }
a9b016e4b248atagen2026-10-10 08:12 UTC688
            if technique == Technique::Stretch && !self.teapot {
a9b016e4b248atagen2026-10-10 08:12 UTC689
                self.teapot = true;
a9b016e4b248atagen2026-10-10 08:12 UTC690
                self.shame += 5;
a9b016e4b248atagen2026-10-10 08:12 UTC691
                return Err(timed(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC692
                    StatusCode::IM_A_TEAPOT,
a9b016e4b248atagen2026-10-10 08:12 UTC693
                    "teapot",
a9b016e4b248atagen2026-10-10 08:12 UTC694
                    "The subject is now short and stout. Length gains are halved for the rest of the run.",
a9b016e4b248atagen2026-10-10 08:12 UTC695
                )));
a9b016e4b248atagen2026-10-10 08:12 UTC696
            }
a9b016e4b248atagen2026-10-10 08:12 UTC697
            return Err(timed(not_extended()));
a9b016e4b248atagen2026-10-10 08:12 UTC698
        }
a9b016e4b248atagen2026-10-10 08:12 UTC699
        if self.eq > 90 && total < dc {
a9b016e4b248atagen2026-10-10 08:12 UTC700
            return Err(timed(self.rupture()));
a9b016e4b248atagen2026-10-10 08:12 UTC701
        }
a9b016e4b248atagen2026-10-10 08:12 UTC702
        if natural == 20 {
a9b016e4b248atagen2026-10-10 08:12 UTC703
            self.gain(length * 2.0, girth * 2.0, profile.foreskin_growth);
a9b016e4b248atagen2026-10-10 08:12 UTC704
            return Ok(Outcome::new(
a9b016e4b248atagen2026-10-10 08:12 UTC705
                StatusCode::CREATED,
a9b016e4b248atagen2026-10-10 08:12 UTC706
                Some(json!({
a9b016e4b248atagen2026-10-10 08:12 UTC707
                    "detail": "A permanent gain has been created.",
a9b016e4b248atagen2026-10-10 08:12 UTC708
                    "length": round3(length * 2.0),
a9b016e4b248atagen2026-10-10 08:12 UTC709
                    "girth": round3(girth * 2.0),
a9b016e4b248atagen2026-10-10 08:12 UTC710
                })),
a9b016e4b248atagen2026-10-10 08:12 UTC711
            )
a9b016e4b248atagen2026-10-10 08:12 UTC712
            .header(header::LOCATION, format!("/runs/{}/length", self.id))
a9b016e4b248atagen2026-10-10 08:12 UTC713
            .header(SERVER_TIMING, timing));
a9b016e4b248atagen2026-10-10 08:12 UTC714
        }
a9b016e4b248atagen2026-10-10 08:12 UTC715
        if total < dc - 3 {
a9b016e4b248atagen2026-10-10 08:12 UTC716
            return Err(timed(not_extended()));
a9b016e4b248atagen2026-10-10 08:12 UTC717
        }
a9b016e4b248atagen2026-10-10 08:12 UTC718
        if total < dc {
a9b016e4b248atagen2026-10-10 08:12 UTC719
            return Ok(Outcome::new(StatusCode::NO_CONTENT, None).header(SERVER_TIMING, timing));
a9b016e4b248atagen2026-10-10 08:12 UTC720
        }
a9b016e4b248atagen2026-10-10 08:12 UTC721
a9b016e4b248atagen2026-10-10 08:12 UTC722
        let outcome = if self.eq < 50 {
a9b016e4b248atagen2026-10-10 08:12 UTC723
            self.gain(length / 2.0, girth / 2.0, profile.foreskin_growth);
a9b016e4b248atagen2026-10-10 08:12 UTC724
            Outcome::new(
a9b016e4b248atagen2026-10-10 08:12 UTC725
                StatusCode::PARTIAL_CONTENT,
a9b016e4b248atagen2026-10-10 08:12 UTC726
                Some(json!({
a9b016e4b248atagen2026-10-10 08:12 UTC727
                    "detail": "Partial gains were applied. EQ was below the band.",
a9b016e4b248atagen2026-10-10 08:12 UTC728
                    "length": round3(length / 2.0),
a9b016e4b248atagen2026-10-10 08:12 UTC729
                    "girth": round3(girth / 2.0),
a9b016e4b248atagen2026-10-10 08:12 UTC730
                })),
a9b016e4b248atagen2026-10-10 08:12 UTC731
            )
a9b016e4b248atagen2026-10-10 08:12 UTC732
        } else if total >= dc + 5 {
a9b016e4b248atagen2026-10-10 08:12 UTC733
            self.gain(length, girth, profile.foreskin_growth);
a9b016e4b248atagen2026-10-10 08:12 UTC734
            Outcome::new(
a9b016e4b248atagen2026-10-10 08:12 UTC735
                StatusCode::OK,
a9b016e4b248atagen2026-10-10 08:12 UTC736
                Some(json!({
a9b016e4b248atagen2026-10-10 08:12 UTC737
                    "detail": "Gains were applied.",
a9b016e4b248atagen2026-10-10 08:12 UTC738
                    "length": round3(length),
a9b016e4b248atagen2026-10-10 08:12 UTC739
                    "girth": round3(girth),
a9b016e4b248atagen2026-10-10 08:12 UTC740
                })),
a9b016e4b248atagen2026-10-10 08:12 UTC741
            )
a9b016e4b248atagen2026-10-10 08:12 UTC742
        } else {
a9b016e4b248atagen2026-10-10 08:12 UTC743
            let eta = self.rng.random_range(1..=4);
a9b016e4b248atagen2026-10-10 08:12 UTC744
            self.pending.push(Pending {
a9b016e4b248atagen2026-10-10 08:12 UTC745
                due: self.turn + eta,
a9b016e4b248atagen2026-10-10 08:12 UTC746
                length,
a9b016e4b248atagen2026-10-10 08:12 UTC747
                girth,
a9b016e4b248atagen2026-10-10 08:12 UTC748
            });
a9b016e4b248atagen2026-10-10 08:12 UTC749
            self.gain(0.0, 0.0, profile.foreskin_growth);
a9b016e4b248atagen2026-10-10 08:12 UTC750
            Outcome::new(
a9b016e4b248atagen2026-10-10 08:12 UTC751
                StatusCode::ACCEPTED,
a9b016e4b248atagen2026-10-10 08:12 UTC752
                Some(json!({
a9b016e4b248atagen2026-10-10 08:12 UTC753
                    "detail": "Gains have been queued. They may arrive.",
a9b016e4b248atagen2026-10-10 08:12 UTC754
                    "eta_turns": eta,
a9b016e4b248atagen2026-10-10 08:12 UTC755
                })),
a9b016e4b248atagen2026-10-10 08:12 UTC756
            )
a9b016e4b248atagen2026-10-10 08:12 UTC757
        };
a9b016e4b248atagen2026-10-10 08:12 UTC758
        Ok(outcome.header(SERVER_TIMING, timing))
a9b016e4b248atagen2026-10-10 08:12 UTC759
    }
a9b016e4b248atagen2026-10-10 08:12 UTC760
a9b016e4b248atagen2026-10-10 08:12 UTC761
    fn utility(&mut self, technique: Technique) -> Result<Outcome, Problem> {
a9b016e4b248atagen2026-10-10 08:12 UTC762
        if technique == Technique::Warmup {
a9b016e4b248atagen2026-10-10 08:12 UTC763
            self.touch();
a9b016e4b248atagen2026-10-10 08:12 UTC764
            self.warmed_up = true;
a9b016e4b248atagen2026-10-10 08:12 UTC765
            self.eq = (self.eq + 25).min(100);
a9b016e4b248atagen2026-10-10 08:12 UTC766
            return Ok(Outcome::new(
a9b016e4b248atagen2026-10-10 08:12 UTC767
                StatusCode::OK,
a9b016e4b248atagen2026-10-10 08:12 UTC768
                Some(json!({ "detail": "Warmed up. The towel has cooled." })),
a9b016e4b248atagen2026-10-10 08:12 UTC769
            ));
a9b016e4b248atagen2026-10-10 08:12 UTC770
        }
a9b016e4b248atagen2026-10-10 08:12 UTC771
        if self.ice_packs == 0 {
a9b016e4b248atagen2026-10-10 08:12 UTC772
            return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC773
                StatusCode::FAILED_DEPENDENCY,
a9b016e4b248atagen2026-10-10 08:12 UTC774
                "no-ice",
a9b016e4b248atagen2026-10-10 08:12 UTC775
                "The ice pack has been used. There was one ice pack.",
a9b016e4b248atagen2026-10-10 08:12 UTC776
            ));
a9b016e4b248atagen2026-10-10 08:12 UTC777
        }
a9b016e4b248atagen2026-10-10 08:12 UTC778
        self.touch();
a9b016e4b248atagen2026-10-10 08:12 UTC779
        self.ice_packs -= 1;
a9b016e4b248atagen2026-10-10 08:12 UTC780
        self.eq = (self.eq - 40).max(0);
a9b016e4b248atagen2026-10-10 08:12 UTC781
        Ok(Outcome::new(
a9b016e4b248atagen2026-10-10 08:12 UTC782
            StatusCode::OK,
a9b016e4b248atagen2026-10-10 08:12 UTC783
            Some(json!({ "detail": "EQ has been reduced. The ice pack is gone." })),
a9b016e4b248atagen2026-10-10 08:12 UTC784
        ))
a9b016e4b248atagen2026-10-10 08:12 UTC785
    }
a9b016e4b248atagen2026-10-10 08:12 UTC786
a9b016e4b248atagen2026-10-10 08:12 UTC787
    fn branch_offer(&self) -> Outcome {
a9b016e4b248atagen2026-10-10 08:12 UTC788
        let forum = format!("/runs/{}/branches/forum", self.id);
a9b016e4b248atagen2026-10-10 08:12 UTC789
        let close = format!("/runs/{}/branches/close-tab", self.id);
a9b016e4b248atagen2026-10-10 08:12 UTC790
        Outcome::new(
a9b016e4b248atagen2026-10-10 08:12 UTC791
            StatusCode::MULTIPLE_CHOICES,
a9b016e4b248atagen2026-10-10 08:12 UTC792
            Some(json!({
a9b016e4b248atagen2026-10-10 08:12 UTC793
                "detail": "A browser tab is open. It has been open for some time.",
a9b016e4b248atagen2026-10-10 08:12 UTC794
                "choices": [
a9b016e4b248atagen2026-10-10 08:12 UTC795
                    { "href": forum, "method": "POST", "title": "Read the forum" },
a9b016e4b248atagen2026-10-10 08:12 UTC796
                    { "href": close, "method": "POST", "title": "Close the tab" },
a9b016e4b248atagen2026-10-10 08:12 UTC797
                ],
a9b016e4b248atagen2026-10-10 08:12 UTC798
            })),
a9b016e4b248atagen2026-10-10 08:12 UTC799
        )
a9b016e4b248atagen2026-10-10 08:12 UTC800
        .header(
a9b016e4b248atagen2026-10-10 08:12 UTC801
            header::LINK,
a9b016e4b248atagen2026-10-10 08:12 UTC802
            format!(
a9b016e4b248atagen2026-10-10 08:12 UTC803
                "<{forum}>; rel=\"alternate\"; title=\"Read the forum\", <{close}>; rel=\"alternate\"; title=\"Close the tab\""
a9b016e4b248atagen2026-10-10 08:12 UTC804
            ),
a9b016e4b248atagen2026-10-10 08:12 UTC805
        )
a9b016e4b248atagen2026-10-10 08:12 UTC806
    }
a9b016e4b248atagen2026-10-10 08:12 UTC807
a9b016e4b248atagen2026-10-10 08:12 UTC808
    pub fn choose(&mut self, choice: &str) -> Result<Outcome, Problem> {
a9b016e4b248atagen2026-10-10 08:12 UTC809
        if !self.branch_pending {
a9b016e4b248atagen2026-10-10 08:12 UTC810
            return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC811
                StatusCode::CONFLICT,
a9b016e4b248atagen2026-10-10 08:12 UTC812
                "no-decision-pending",
a9b016e4b248atagen2026-10-10 08:12 UTC813
                "There is no decision pending.",
a9b016e4b248atagen2026-10-10 08:12 UTC814
            ));
a9b016e4b248atagen2026-10-10 08:12 UTC815
        }
a9b016e4b248atagen2026-10-10 08:12 UTC816
        let (location, detail) = match choice {
a9b016e4b248atagen2026-10-10 08:12 UTC817
            "forum" => {
a9b016e4b248atagen2026-10-10 08:12 UTC818
                self.forum_read = true;
a9b016e4b248atagen2026-10-10 08:12 UTC819
                self.shame += 3;
a9b016e4b248atagen2026-10-10 08:12 UTC820
                (
a9b016e4b248atagen2026-10-10 08:12 UTC821
                    format!("/runs/{}/techniques", self.id),
a9b016e4b248atagen2026-10-10 08:12 UTC822
                    "The thread is from 2009. A technique called uli is discussed at length.",
a9b016e4b248atagen2026-10-10 08:12 UTC823
                )
a9b016e4b248atagen2026-10-10 08:12 UTC824
            }
a9b016e4b248atagen2026-10-10 08:12 UTC825
            "close-tab" => {
a9b016e4b248atagen2026-10-10 08:12 UTC826
                self.suspicion = 0;
a9b016e4b248atagen2026-10-10 08:12 UTC827
                (
a9b016e4b248atagen2026-10-10 08:12 UTC828
                    format!("/runs/{}", self.id),
a9b016e4b248atagen2026-10-10 08:12 UTC829
                    "The tab is closed. Nobody saw it.",
a9b016e4b248atagen2026-10-10 08:12 UTC830
                )
a9b016e4b248atagen2026-10-10 08:12 UTC831
            }
a9b016e4b248atagen2026-10-10 08:12 UTC832
            _ => {
a9b016e4b248atagen2026-10-10 08:12 UTC833
                return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC834
                    StatusCode::NOT_FOUND,
a9b016e4b248atagen2026-10-10 08:12 UTC835
                    "no-such-choice",
a9b016e4b248atagen2026-10-10 08:12 UTC836
                    "That was not one of the choices.",
a9b016e4b248atagen2026-10-10 08:12 UTC837
                ));
a9b016e4b248atagen2026-10-10 08:12 UTC838
            }
a9b016e4b248atagen2026-10-10 08:12 UTC839
        };
a9b016e4b248atagen2026-10-10 08:12 UTC840
        self.branch_pending = false;
a9b016e4b248atagen2026-10-10 08:12 UTC841
        self.touch();
a9b016e4b248atagen2026-10-10 08:12 UTC842
        Ok(
a9b016e4b248atagen2026-10-10 08:12 UTC843
            Outcome::new(StatusCode::SEE_OTHER, Some(json!({ "detail": detail })))
a9b016e4b248atagen2026-10-10 08:12 UTC844
                .header(header::LOCATION, location),
a9b016e4b248atagen2026-10-10 08:12 UTC845
        )
a9b016e4b248atagen2026-10-10 08:12 UTC846
    }
a9b016e4b248atagen2026-10-10 08:12 UTC847
a9b016e4b248atagen2026-10-10 08:12 UTC848
    pub fn search_drawer(&mut self) -> Outcome {
a9b016e4b248atagen2026-10-10 08:12 UTC849
        if self.ruler {
a9b016e4b248atagen2026-10-10 08:12 UTC850
            return Outcome::new(StatusCode::NO_CONTENT, None);
a9b016e4b248atagen2026-10-10 08:12 UTC851
        }
a9b016e4b248atagen2026-10-10 08:12 UTC852
        self.ruler = true;
a9b016e4b248atagen2026-10-10 08:12 UTC853
        self.shame += 10;
a9b016e4b248atagen2026-10-10 08:12 UTC854
        self.touch();
a9b016e4b248atagen2026-10-10 08:12 UTC855
        Outcome::new(
a9b016e4b248atagen2026-10-10 08:12 UTC856
            StatusCode::FOUND,
a9b016e4b248atagen2026-10-10 08:12 UTC857
            Some(json!({
a9b016e4b248atagen2026-10-10 08:12 UTC858
                "detail": "You now know. This is the first honest measurement of the run.",
a9b016e4b248atagen2026-10-10 08:12 UTC859
            })),
a9b016e4b248atagen2026-10-10 08:12 UTC860
        )
a9b016e4b248atagen2026-10-10 08:12 UTC861
        .header(
a9b016e4b248atagen2026-10-10 08:12 UTC862
            header::LOCATION,
a9b016e4b248atagen2026-10-10 08:12 UTC863
            format!("/runs/{}/inventory/ruler", self.id),
a9b016e4b248atagen2026-10-10 08:12 UTC864
        )
a9b016e4b248atagen2026-10-10 08:12 UTC865
    }
a9b016e4b248atagen2026-10-10 08:12 UTC866
a9b016e4b248atagen2026-10-10 08:12 UTC867
    pub fn put(&mut self) -> Outcome {
a9b016e4b248atagen2026-10-10 08:12 UTC868
        if self.put_applied {
a9b016e4b248atagen2026-10-10 08:12 UTC869
            return Outcome::new(StatusCode::NO_CONTENT, None);
a9b016e4b248atagen2026-10-10 08:12 UTC870
        }
a9b016e4b248atagen2026-10-10 08:12 UTC871
        self.put_applied = true;
a9b016e4b248atagen2026-10-10 08:12 UTC872
        self.length += 0.01;
a9b016e4b248atagen2026-10-10 08:12 UTC873
        self.touch();
a9b016e4b248atagen2026-10-10 08:12 UTC874
        Outcome::new(StatusCode::OK, Some(json!({ "detail": "Applied." })))
a9b016e4b248atagen2026-10-10 08:12 UTC875
    }
a9b016e4b248atagen2026-10-10 08:12 UTC876
a9b016e4b248atagen2026-10-10 08:12 UTC877
    pub fn patch(&mut self, ops: &[PatchOp]) -> Result<Outcome, Problem> {
a9b016e4b248atagen2026-10-10 08:12 UTC878
        for op in ops {
a9b016e4b248atagen2026-10-10 08:12 UTC879
            if op.op != "test" {
a9b016e4b248atagen2026-10-10 08:12 UTC880
                return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC881
                    StatusCode::UNPROCESSABLE_ENTITY,
a9b016e4b248atagen2026-10-10 08:12 UTC882
                    "wishful-thinking",
a9b016e4b248atagen2026-10-10 08:12 UTC883
                    "The server accepts only the truth.",
a9b016e4b248atagen2026-10-10 08:12 UTC884
                )
a9b016e4b248atagen2026-10-10 08:12 UTC885
                .with("op", op.op.clone()));
a9b016e4b248atagen2026-10-10 08:12 UTC886
            }
a9b016e4b248atagen2026-10-10 08:12 UTC887
            let actual = match op.path.as_str() {
a9b016e4b248atagen2026-10-10 08:12 UTC888
                "/length" => self.length,
a9b016e4b248atagen2026-10-10 08:12 UTC889
                "/girth" => self.girth,
a9b016e4b248atagen2026-10-10 08:12 UTC890
                "/foreskin" => self.foreskin.unwrap_or(0.0),
a9b016e4b248atagen2026-10-10 08:12 UTC891
                _ => {
a9b016e4b248atagen2026-10-10 08:12 UTC892
                    return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC893
                        StatusCode::UNPROCESSABLE_ENTITY,
a9b016e4b248atagen2026-10-10 08:12 UTC894
                        "no-such-measurement",
a9b016e4b248atagen2026-10-10 08:12 UTC895
                        "There is no such measurement.",
a9b016e4b248atagen2026-10-10 08:12 UTC896
                    )
a9b016e4b248atagen2026-10-10 08:12 UTC897
                    .with("path", op.path.clone()));
a9b016e4b248atagen2026-10-10 08:12 UTC898
                }
a9b016e4b248atagen2026-10-10 08:12 UTC899
            };
a9b016e4b248atagen2026-10-10 08:12 UTC900
            let Some(claimed) = op.value.as_f64() else {
a9b016e4b248atagen2026-10-10 08:12 UTC901
                return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC902
                    StatusCode::BAD_REQUEST,
a9b016e4b248atagen2026-10-10 08:12 UTC903
                    "malformed-patch",
a9b016e4b248atagen2026-10-10 08:12 UTC904
                    "A test requires a numeric value.",
a9b016e4b248atagen2026-10-10 08:12 UTC905
                ));
a9b016e4b248atagen2026-10-10 08:12 UTC906
            };
a9b016e4b248atagen2026-10-10 08:12 UTC907
            if op.path == "/length" && claimed >= self.length + 1.0 {
a9b016e4b248atagen2026-10-10 08:12 UTC908
                self.shame += 2;
a9b016e4b248atagen2026-10-10 08:12 UTC909
                self.touch();
a9b016e4b248atagen2026-10-10 08:12 UTC910
                return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC911
                    StatusCode::BAD_REQUEST,
a9b016e4b248atagen2026-10-10 08:12 UTC912
                    "exaggeration",
a9b016e4b248atagen2026-10-10 08:12 UTC913
                    "The server has measured. Lying is not a technique.",
a9b016e4b248atagen2026-10-10 08:12 UTC914
                ));
a9b016e4b248atagen2026-10-10 08:12 UTC915
            }
a9b016e4b248atagen2026-10-10 08:12 UTC916
            if tenths(claimed) != tenths(actual) {
a9b016e4b248atagen2026-10-10 08:12 UTC917
                self.shame += 1;
a9b016e4b248atagen2026-10-10 08:12 UTC918
                self.touch();
a9b016e4b248atagen2026-10-10 08:12 UTC919
                return Err(
a9b016e4b248atagen2026-10-10 08:12 UTC920
                    Problem::new(StatusCode::CONFLICT, "test-failed", "The test failed.")
a9b016e4b248atagen2026-10-10 08:12 UTC921
                        .with("path", op.path.clone()),
a9b016e4b248atagen2026-10-10 08:12 UTC922
                );
a9b016e4b248atagen2026-10-10 08:12 UTC923
            }
a9b016e4b248atagen2026-10-10 08:12 UTC924
        }
a9b016e4b248atagen2026-10-10 08:12 UTC925
        Ok(Outcome::new(
a9b016e4b248atagen2026-10-10 08:12 UTC926
            StatusCode::OK,
a9b016e4b248atagen2026-10-10 08:12 UTC927
            Some(json!({ "detail": "Correct." })),
a9b016e4b248atagen2026-10-10 08:12 UTC928
        ))
a9b016e4b248atagen2026-10-10 08:12 UTC929
    }
a9b016e4b248atagen2026-10-10 08:12 UTC930
a9b016e4b248atagen2026-10-10 08:12 UTC931
    pub fn delete_foreskin(&mut self, if_match: Option<&str>) -> Result<Outcome, Problem> {
a9b016e4b248atagen2026-10-10 08:12 UTC932
        if self.foreskin.is_none() {
a9b016e4b248atagen2026-10-10 08:12 UTC933
            return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC934
                StatusCode::GONE,
a9b016e4b248atagen2026-10-10 08:12 UTC935
                "foreskin-gone",
a9b016e4b248atagen2026-10-10 08:12 UTC936
                "The foreskin is gone. It will not be returning.",
a9b016e4b248atagen2026-10-10 08:12 UTC937
            ));
a9b016e4b248atagen2026-10-10 08:12 UTC938
        }
a9b016e4b248atagen2026-10-10 08:12 UTC939
        let Some(if_match) = if_match else {
a9b016e4b248atagen2026-10-10 08:12 UTC940
            return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC941
                StatusCode::PRECONDITION_REQUIRED,
a9b016e4b248atagen2026-10-10 08:12 UTC942
                "confirmation-required",
a9b016e4b248atagen2026-10-10 08:12 UTC943
                "The server requires you to be sure. Send If-Match.",
a9b016e4b248atagen2026-10-10 08:12 UTC944
            ));
a9b016e4b248atagen2026-10-10 08:12 UTC945
        };
a9b016e4b248atagen2026-10-10 08:12 UTC946
        if !etag_matches(if_match, &self.etag()) {
a9b016e4b248atagen2026-10-10 08:12 UTC947
            return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC948
                StatusCode::PRECONDITION_FAILED,
a9b016e4b248atagen2026-10-10 08:12 UTC949
                "stale-foreskin",
a9b016e4b248atagen2026-10-10 08:12 UTC950
                "The foreskin has changed since you last looked at it.",
a9b016e4b248atagen2026-10-10 08:12 UTC951
            ));
a9b016e4b248atagen2026-10-10 08:12 UTC952
        }
a9b016e4b248atagen2026-10-10 08:12 UTC953
        self.foreskin = None;
a9b016e4b248atagen2026-10-10 08:12 UTC954
        self.shame += 20;
a9b016e4b248atagen2026-10-10 08:12 UTC955
        self.touch();
a9b016e4b248atagen2026-10-10 08:12 UTC956
        Ok(Outcome::new(StatusCode::NO_CONTENT, None))
a9b016e4b248atagen2026-10-10 08:12 UTC957
    }
a9b016e4b248atagen2026-10-10 08:12 UTC958
a9b016e4b248atagen2026-10-10 08:12 UTC959
    pub fn diagnose(
a9b016e4b248atagen2026-10-10 08:12 UTC960
        &mut self,
a9b016e4b248atagen2026-10-10 08:12 UTC961
        authorization: Option<&str>,
a9b016e4b248atagen2026-10-10 08:12 UTC962
        if_none_match: Option<&str>,
a9b016e4b248atagen2026-10-10 08:12 UTC963
    ) -> Result<Outcome, Problem> {
a9b016e4b248atagen2026-10-10 08:12 UTC964
        let remaining = APPOINTMENT_TURN.saturating_sub(self.turn);
a9b016e4b248atagen2026-10-10 08:12 UTC965
        if remaining > 0 {
a9b016e4b248atagen2026-10-10 08:12 UTC966
            return Err(Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC967
                StatusCode::TOO_EARLY,
a9b016e4b248atagen2026-10-10 08:12 UTC968
                "no-appointment",
a9b016e4b248atagen2026-10-10 08:12 UTC969
                format!("The clinic's next available appointment is in {remaining} turns."),
a9b016e4b248atagen2026-10-10 08:12 UTC970
            ));
a9b016e4b248atagen2026-10-10 08:12 UTC971
        }
a9b016e4b248atagen2026-10-10 08:12 UTC972
        let intake = Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC973
            StatusCode::UNAUTHORIZED,
a9b016e4b248atagen2026-10-10 08:12 UTC974
            "intake",
a9b016e4b248atagen2026-10-10 08:12 UTC975
            "The clinic requires authorization.",
a9b016e4b248atagen2026-10-10 08:12 UTC976
        );
a9b016e4b248atagen2026-10-10 08:12 UTC977
        let Some(authorization) = authorization else {
a9b016e4b248atagen2026-10-10 08:12 UTC978
            return Err(intake.header(header::WWW_AUTHENTICATE, "Bearer realm=\"clinic\""));
a9b016e4b248atagen2026-10-10 08:12 UTC979
        };
a9b016e4b248atagen2026-10-10 08:12 UTC980
        let honest = parse_credentials(authorization, "bearer")
a9b016e4b248atagen2026-10-10 08:12 UTC981
            .is_some_and(|token| token.eq_ignore_ascii_case("honesty"));
a9b016e4b248atagen2026-10-10 08:12 UTC982
        if !honest {
a9b016e4b248atagen2026-10-10 08:12 UTC983
            let hint = INTAKE[self.intake_attempts.min(INTAKE.len() - 1)];
a9b016e4b248atagen2026-10-10 08:12 UTC984
            self.intake_attempts += 1;
a9b016e4b248atagen2026-10-10 08:12 UTC985
            return Err(intake.header(
a9b016e4b248atagen2026-10-10 08:12 UTC986
                header::WWW_AUTHENTICATE,
a9b016e4b248atagen2026-10-10 08:12 UTC987
                format!(
a9b016e4b248atagen2026-10-10 08:12 UTC988
                    "Bearer realm=\"clinic\", error=\"invalid_token\", error_description=\"{hint}\""
a9b016e4b248atagen2026-10-10 08:12 UTC989
                ),
a9b016e4b248atagen2026-10-10 08:12 UTC990
            ));
a9b016e4b248atagen2026-10-10 08:12 UTC991
        }
a9b016e4b248atagen2026-10-10 08:12 UTC992
a9b016e4b248atagen2026-10-10 08:12 UTC993
        let etag = self.origin_etag.clone();
a9b016e4b248atagen2026-10-10 08:12 UTC994
        if if_none_match.is_some_and(|inm| etag_matches(inm, &etag)) {
a9b016e4b248atagen2026-10-10 08:12 UTC995
            self.cured = true;
a9b016e4b248atagen2026-10-10 08:12 UTC996
            return Ok(Outcome::new(StatusCode::NOT_MODIFIED, None)
a9b016e4b248atagen2026-10-10 08:12 UTC997
                .header(header::ETAG, etag)
a9b016e4b248atagen2026-10-10 08:12 UTC998
                .header(X_DIAGNOSIS, "normal"));
a9b016e4b248atagen2026-10-10 08:12 UTC999
        }
a9b016e4b248atagen2026-10-10 08:12 UTC1000
        if !self.measured {
a9b016e4b248atagen2026-10-10 08:12 UTC1001
            self.measured = true;
a9b016e4b248atagen2026-10-10 08:12 UTC1002
            if !self.ruler {
a9b016e4b248atagen2026-10-10 08:12 UTC1003
                self.shame += 15;
a9b016e4b248atagen2026-10-10 08:12 UTC1004
            }
a9b016e4b248atagen2026-10-10 08:12 UTC1005
        }
a9b016e4b248atagen2026-10-10 08:12 UTC1006
        Ok(Outcome::new(
a9b016e4b248atagen2026-10-10 08:12 UTC1007
            StatusCode::OK,
a9b016e4b248atagen2026-10-10 08:12 UTC1008
            Some(json!({
a9b016e4b248atagen2026-10-10 08:12 UTC1009
                "diagnosis": "within normal range",
a9b016e4b248atagen2026-10-10 08:12 UTC1010
                "measured_length": round2(self.length),
a9b016e4b248atagen2026-10-10 08:12 UTC1011
                "you_said": round2(self.self_reported()),
a9b016e4b248atagen2026-10-10 08:12 UTC1012
                "prescription": "Please stop.",
a9b016e4b248atagen2026-10-10 08:12 UTC1013
            })),
a9b016e4b248atagen2026-10-10 08:12 UTC1014
        )
a9b016e4b248atagen2026-10-10 08:12 UTC1015
        .header(header::ETAG, etag))
a9b016e4b248atagen2026-10-10 08:12 UTC1016
    }
a9b016e4b248atagen2026-10-10 08:12 UTC1017
}
a9b016e4b248atagen2026-10-10 08:12 UTC1018
a9b016e4b248atagen2026-10-10 08:12 UTC1019
pub fn etag_matches(condition: &str, etag: &str) -> bool {
a9b016e4b248atagen2026-10-10 08:12 UTC1020
    condition
a9b016e4b248atagen2026-10-10 08:12 UTC1021
        .split(',')
a9b016e4b248atagen2026-10-10 08:12 UTC1022
        .map(str::trim)
a9b016e4b248atagen2026-10-10 08:12 UTC1023
        .any(|tag| tag == "*" || tag.trim_start_matches("W/") == etag)
a9b016e4b248atagen2026-10-10 08:12 UTC1024
}
a9b016e4b248atagen2026-10-10 08:12 UTC1025
a9b016e4b248atagen2026-10-10 08:12 UTC1026
pub fn shaft(mm: usize) -> String {
a9b016e4b248atagen2026-10-10 08:12 UTC1027
    format!("8{}D", "=".repeat(mm.saturating_sub(2)))
a9b016e4b248atagen2026-10-10 08:12 UTC1028
}