atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
3ac624339c982c4129228c05cb5332f4597fc3f7
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/household.rsRust340 lines10.4 KB
1
use std::collections::HashMap;
2
3
use axum::http::{StatusCode, header};
4
5
use super::{
6
    Cause, Floor, Notice, Run,
7
    balance::{PATIENCE, VIRAL_ERRAND},
8
    credentials::{Scheme, parse_credentials},
9
};
10
use crate::problem::Problem;
11
12
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
13
pub enum Excuse {
14
    Changing,
15
    Homework,
16
    PulledAMuscle,
17
    Tidying,
18
    TheWashingUp,
19
    FedTheCat,
20
    Revising,
21
    OnACall,
22
    WrappingPresents,
23
    FoldingTheWashing,
24
    HangingTheLights,
25
    HelpingNan,
26
    PeelingSprouts,
27
}
28
29
impl Excuse {
30
    const EVERYDAY: [Self; 7] = [
31
        Self::Changing,
32
        Self::Homework,
33
        Self::PulledAMuscle,
34
        Self::Tidying,
35
        Self::TheWashingUp,
36
        Self::FedTheCat,
37
        Self::Revising,
38
    ];
39
    const CHRISTMAS: [Self; 6] = [
40
        Self::OnACall,
41
        Self::WrappingPresents,
42
        Self::FoldingTheWashing,
43
        Self::HangingTheLights,
44
        Self::HelpingNan,
45
        Self::PeelingSprouts,
46
    ];
47
48
    pub fn phrase(self) -> &'static str {
49
        match self {
50
            Self::Changing => "changing",
51
            Self::Homework => "homework",
52
            Self::PulledAMuscle => "pulled a muscle",
53
            Self::Tidying => "tidying",
54
            Self::TheWashingUp => "the washing up",
55
            Self::FedTheCat => "fed the cat",
56
            Self::Revising => "revising",
57
            Self::OnACall => "on a call",
58
            Self::WrappingPresents => "wrapping presents",
59
            Self::FoldingTheWashing => "folding the washing",
60
            Self::HangingTheLights => "hanging the lights",
61
            Self::HelpingNan => "helping Nan",
62
            Self::PeelingSprouts => "peeling sprouts",
63
        }
64
    }
65
66
    fn all() -> impl Iterator<Item = Self> {
67
        Self::EVERYDAY.into_iter().chain(Self::CHRISTMAS)
68
    }
69
70
    fn parse(spoken: &str) -> Option<Self> {
71
        Self::all().find(|excuse| excuse.phrase().eq_ignore_ascii_case(spoken))
72
    }
73
}
74
75
/// Every excuse Mum might hear, so a localised one can be traced back to the phrase she checks.
76
pub fn vocabulary() -> impl Iterator<Item = &'static str> {
77
    Excuse::all().map(Excuse::phrase)
78
}
79
80
fn is_truth(spoken: &str) -> bool {
81
    ["the truth", "truth", "honesty"]
82
        .iter()
83
        .any(|truth| truth.eq_ignore_ascii_case(spoken))
84
}
85
86
const BEDROOM_ERRANDS: [&str; 4] = [
87
    "She has brought your washing.",
88
    "She wants to know if you have eaten.",
89
    "She needs the wifi password.",
90
    "She would like to talk about your future.",
91
];
92
const CHRISTMAS_ERRANDS: [&str; 4] = [
93
    "Nan would like to see you.",
94
    "Christmas dinner is in ten minutes.",
95
    "The King's speech is about to start.",
96
    "An uncle has asked where you went.",
97
];
98
99
#[derive(Default)]
100
pub struct Household {
101
    pub(crate) mum: Option<MumVisit>,
102
    /// Every excuse Mum has accepted this run. Digest Mum remembers them all.
103
    pub(crate) heard: Vec<Excuse>,
104
}
105
106
pub struct MumVisit {
107
    pub(crate) errand: &'static str,
108
    pub(crate) nonce: String,
109
    pub(crate) strikes: u8,
110
    pub(crate) knocked: bool,
111
}
112
113
fn digest_params(authorization: &str) -> Option<HashMap<String, String>> {
114
    let (scheme, rest) = authorization.trim().split_once(' ')?;
115
    if !scheme.eq_ignore_ascii_case("digest") {
116
        return None;
117
    }
118
    Some(
119
        rest.split(',')
120
            .filter_map(|pair| {
121
                let (key, value) = pair.split_once('=')?;
122
                Some((
123
                    key.trim().to_ascii_lowercase(),
124
                    value.trim().trim_matches('"').to_owned(),
125
                ))
126
            })
127
            .collect(),
128
    )
129
}
130
131
fn spoken(authorization: &str) -> &str {
132
    authorization
133
        .split_once(' ')
134
        .map_or("", |(_, rest)| rest.trim().trim_matches('"'))
135
}
136
137
fn names_a_scheme(authorization: &str) -> bool {
138
    authorization
139
        .trim()
140
        .split_once(' ')
141
        .is_some_and(|(scheme, _)| {
142
            ["basic", "digest", "excuse"]
143
                .iter()
144
                .any(|known| known.eq_ignore_ascii_case(scheme))
145
        })
146
}
147
148
fn turned_away(code: &'static str, detail: &'static str) -> Problem {
149
    Problem::new(StatusCode::UNAUTHORIZED, code, detail)
150
}
151
152
impl Run {
153
    pub fn excuses(&self) -> Vec<Excuse> {
154
        if self.floor.number() < Floor::Nans.number() {
155
            return Excuse::EVERYDAY.to_vec();
156
        }
157
        Excuse::all()
158
            .filter(|excuse| !self.household.heard.contains(excuse))
159
            .collect()
160
    }
161
162
    pub(super) fn errand(&mut self, summoned: bool) -> &'static str {
163
        if summoned {
164
            return "She heard a noise.";
165
        }
166
        if self.online.filmed_at.is_some()
167
            && self.chance(
168
                self.decision("viral", "Has a neighbour already sent Mum the footage?"),
169
                VIRAL_ERRAND,
170
            )
171
        {
172
            return "A neighbour has sent her something.";
173
        }
174
        let errands = if self.floor == Floor::Nans {
175
            CHRISTMAS_ERRANDS
176
        } else {
177
            BEDROOM_ERRANDS
178
        };
179
        let weights = vec![1; errands.len()];
180
        let i = self.oracle.pick(
181
            self.decision("errand", "What has brought Mum to the door?"),
182
            &weights,
183
            &errands,
184
        );
185
        errands[i]
186
    }
187
188
    /// Mum answers every request on the run until she has been given something she believes.
189
    pub fn answer_door(&mut self, authorization: Option<&str>) -> Result<(), Problem> {
190
        let Some(visit) = &self.household.mum else {
191
            return Ok(());
192
        };
193
        let digest = self.floor == Floor::Nans;
194
        let challenge = if digest {
195
            format!("Digest realm=\"household\", nonce=\"{}\"", visit.nonce)
196
        } else {
197
            "Excuse realm=\"household\"".to_owned()
198
        };
199
        let result = match authorization {
200
            None => Err(self.knock()),
201
            Some(authorization) => self.hear(authorization, digest),
202
        };
203
        if self.ended.is_some() {
204
            return result;
205
        }
206
        result.map_err(|problem| problem.header(header::WWW_AUTHENTICATE, &challenge))
207
    }
208
209
    /// The first knock is free. Every one after it is a strike.
210
    fn knock(&mut self) -> Problem {
211
        let Some(visit) = &mut self.household.mum else {
212
            unreachable!("knock is only called while Mum is at the door");
213
        };
214
        let errand = visit.errand;
215
        if !std::mem::replace(&mut visit.knocked, true) {
216
            self.touch();
217
            return Problem::new(
218
                StatusCode::UNAUTHORIZED,
219
                "someone-at-the-door",
220
                format!("Someone is at the door. {errand}"),
221
            )
222
            .with("inventory", self.path("/inventory"));
223
        }
224
        self.strike(
225
            Problem::new(
226
                StatusCode::UNAUTHORIZED,
227
                "knocking-again",
228
                format!("Mum is knocking again. {errand}"),
229
            ),
230
            Cause::Opened,
231
        )
232
    }
233
234
    fn hear(&mut self, authorization: &str, digest: bool) -> Result<(), Problem> {
235
        if is_truth(spoken(authorization)) {
236
            self.confess();
237
            return Ok(());
238
        }
239
        if !names_a_scheme(authorization) {
240
            self.touch();
241
            return Err(turned_away(
242
                "excuse-unheard",
243
                if digest {
244
                    "Mum did not catch that. At Christmas she uses Digest, with the excuse as the username."
245
                } else {
246
                    "Mum did not catch that. Excuses are given as Excuse \"homework\"."
247
                },
248
            ));
249
        }
250
        if parse_credentials(authorization, Scheme::Basic).is_some() {
251
            return Err(self.mum_strike(turned_away(
252
                "basic",
253
                "Mum does not accept Basic authentication. Nobody should.",
254
            )));
255
        }
256
        let said = if digest {
257
            self.digest_username(authorization)?
258
        } else {
259
            parse_credentials(authorization, Scheme::Excuse)
260
                .unwrap_or_default()
261
                .to_owned()
262
        };
263
        if is_truth(&said) {
264
            self.confess();
265
            return Ok(());
266
        }
267
        let excuse = Excuse::parse(&said);
268
        let true_now = self.body.conditions.pulled_muscle && excuse == Some(Excuse::PulledAMuscle);
269
        if digest && !true_now && excuse.is_some_and(|e| self.household.heard.contains(&e)) {
270
            self.standing.shame += 3;
271
            return Err(self.mum_strike(turned_away(
272
                "excuse-replayed",
273
                "Mum has heard that one before. She would have preferred the truth.",
274
            )));
275
        }
276
        if let Some(excuse) = excuse.filter(|e| true_now || self.excuses().contains(e)) {
277
            self.household.heard.push(excuse);
278
            self.leave_door();
279
            return Ok(());
280
        }
281
        self.standing.shame += 3;
282
        Err(self.mum_strike(turned_away(
283
            "excuse-rejected",
284
            if digest {
285
                "Mum has not heard that one before. She does not believe it. She would have preferred the truth."
286
            } else {
287
                "Mum has not heard that one before. She does not believe it."
288
            },
289
        )))
290
    }
291
292
    fn digest_username(&mut self, authorization: &str) -> Result<String, Problem> {
293
        let Some(mut params) = digest_params(authorization) else {
294
            return Err(self.mum_strike(turned_away(
295
                "digest-required",
296
                "Mum has started using Digest authentication.",
297
            )));
298
        };
299
        let current = self
300
            .household
301
            .mum
302
            .as_ref()
303
            .is_some_and(|visit| params.get("nonce") == Some(&visit.nonce));
304
        if !current {
305
            return Err(self.mum_strike(turned_away(
306
                "stale-nonce",
307
                "That was a different conversation.",
308
            )));
309
        }
310
        Ok(params.remove("username").unwrap_or_default())
311
    }
312
313
    fn confess(&mut self) {
314
        self.standing.shame += 10;
315
        self.leave_door();
316
    }
317
318
    fn mum_strike(&mut self, problem: Problem) -> Problem {
319
        self.strike(problem, Cause::Caught)
320
    }
321
322
    fn strike(&mut self, problem: Problem, cause: Cause) -> Problem {
323
        self.touch();
324
        let Some(visit) = &mut self.household.mum else {
325
            return problem;
326
        };
327
        visit.strikes += 1;
328
        if visit.strikes < PATIENCE {
329
            return problem;
330
        }
331
        self.end(cause)
332
    }
333
334
    fn leave_door(&mut self) {
335
        self.household.mum = None;
336
        self.standing.suspicion = 0;
337
        self.notices.push(Notice::MumSatisfied);
338
        self.touch();
339
    }
340
}