atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
8599b930cebf9a1fb90b7538c44eab5649929cf9
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/household.rsRust399 lines12.1 KB
1
use std::collections::HashMap;
2
3
use axum::http::StatusCode;
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
/// Why Mum came to the door, and how she leaves once she believes you.
87
#[derive(Clone, Copy)]
88
pub struct Errand {
89
    pub(crate) arrival: &'static str,
90
    pub(crate) knock: &'static str,
91
    pub(crate) parting: &'static str,
92
}
93
94
macro_rules! errand {
95
    ($knock:literal, $parting:literal) => {
96
        Errand {
97
            arrival: concat!("Mum is at the door. ", $knock),
98
            knock: $knock,
99
            parting: concat!("Mum seems satisfied. ", $parting),
100
        }
101
    };
102
}
103
104
pub const HEARD_A_NOISE: Errand = errand!("She heard a noise.", "It must have been the pipes.");
105
const VIRAL: Errand = errand!(
106
    "A neighbour has sent her something.",
107
    "She has decided not to open the attachment."
108
);
109
const BEDROOM_ERRANDS: [Errand; 4] = [
110
    errand!(
111
        "She has brought your washing.",
112
        "She leaves the washing on the chair."
113
    ),
114
    errand!(
115
        "She wants to know if you have eaten.",
116
        "There will be a plate in the oven."
117
    ),
118
    errand!(
119
        "She wants the wifi password.",
120
        "She has written the wifi password on her hand."
121
    ),
122
    errand!(
123
        "She would like to talk about your future.",
124
        "The future can wait until dinner."
125
    ),
126
];
127
const CHRISTMAS_ERRANDS: [Errand; 4] = [
128
    errand!(
129
        "Nan would like to see you.",
130
        "She tells Nan you are on your way."
131
    ),
132
    errand!(
133
        "Christmas dinner is in ten minutes.",
134
        "She will save you a pig in a blanket."
135
    ),
136
    errand!(
137
        "The King's speech is about to start.",
138
        "She will tell you what the King said."
139
    ),
140
    errand!(
141
        "An uncle has asked where you went.",
142
        "She tells the uncle you are helping in the kitchen."
143
    ),
144
];
145
146
#[derive(Default)]
147
pub struct Household {
148
    pub(crate) mum: Option<MumVisit>,
149
    /// Every excuse Mum has accepted this run. Digest Mum remembers them all.
150
    pub(crate) heard: Vec<Excuse>,
151
    /// Set when the request now being answered sent Mum away, so the same turn cannot fetch her back.
152
    pub(crate) just_left: bool,
153
}
154
155
pub struct MumVisit {
156
    pub(crate) errand: Errand,
157
    pub(crate) nonce: String,
158
    pub(crate) strikes: u8,
159
    pub(crate) knocked: bool,
160
}
161
162
fn digest_params(authorization: &str) -> Option<HashMap<String, String>> {
163
    let (scheme, rest) = authorization.trim().split_once(' ')?;
164
    if !scheme.eq_ignore_ascii_case("digest") {
165
        return None;
166
    }
167
    Some(
168
        rest.split(',')
169
            .filter_map(|pair| {
170
                let (key, value) = pair.split_once('=')?;
171
                Some((
172
                    key.trim().to_ascii_lowercase(),
173
                    value.trim().trim_matches('"').to_owned(),
174
                ))
175
            })
176
            .collect(),
177
    )
178
}
179
180
fn spoken(authorization: &str) -> &str {
181
    authorization
182
        .split_once(' ')
183
        .map_or("", |(_, rest)| rest.trim().trim_matches('"'))
184
}
185
186
fn names_a_scheme(authorization: &str) -> bool {
187
    authorization
188
        .trim()
189
        .split_once(' ')
190
        .is_some_and(|(scheme, _)| {
191
            ["basic", "digest", "excuse"]
192
                .iter()
193
                .any(|known| known.eq_ignore_ascii_case(scheme))
194
        })
195
}
196
197
fn turned_away(code: &'static str, detail: &'static str) -> Problem {
198
    Problem::new(StatusCode::UNAUTHORIZED, code, detail)
199
}
200
201
impl Run {
202
    pub fn excuses(&self) -> Vec<Excuse> {
203
        if self.floor.number() < Floor::Nans.number() {
204
            return Excuse::EVERYDAY.to_vec();
205
        }
206
        Excuse::all()
207
            .filter(|excuse| !self.household.heard.contains(excuse))
208
            .collect()
209
    }
210
211
    pub(super) fn errand(&mut self, summoned: bool) -> Errand {
212
        if summoned {
213
            return HEARD_A_NOISE;
214
        }
215
        if self.online.filmed_at.is_some()
216
            && self.chance(
217
                self.decision("viral", "Has a neighbour already sent Mum the footage?"),
218
                VIRAL_ERRAND,
219
            )
220
        {
221
            return VIRAL;
222
        }
223
        let errands = if self.floor == Floor::Nans {
224
            CHRISTMAS_ERRANDS
225
        } else {
226
            BEDROOM_ERRANDS
227
        };
228
        let weights = vec![1; errands.len()];
229
        let knocks = errands.map(|errand| errand.knock);
230
        let i = self.oracle.pick(
231
            self.decision("errand", "What has brought Mum to the door?"),
232
            &weights,
233
            &knocks,
234
        );
235
        errands[i]
236
    }
237
238
    /// Mum answers every request on the run until she has been given something she believes.
239
    pub fn answer_door(&mut self, authorization: Option<&str>) -> Result<(), Problem> {
240
        self.household.just_left = false;
241
        if self.household.mum.is_none() {
242
            return Ok(());
243
        }
244
        match authorization {
245
            None => Err(self.knock()),
246
            Some(authorization) => self.hear(authorization, self.floor == Floor::Nans),
247
        }
248
    }
249
250
    /// Sent on every response while she is at the door, so a client always holds the current nonce.
251
    pub fn mum_challenge(&self) -> Option<String> {
252
        let visit = self
253
            .household
254
            .mum
255
            .as_ref()
256
            .filter(|_| self.ended.is_none())?;
257
        Some(if self.floor == Floor::Nans {
258
            format!("Digest realm=\"household\", nonce=\"{}\"", visit.nonce)
259
        } else {
260
            "Excuse realm=\"household\"".to_owned()
261
        })
262
    }
263
264
    /// The first knock is free. Every one after it is a strike.
265
    fn knock(&mut self) -> Problem {
266
        let Some(visit) = &mut self.household.mum else {
267
            unreachable!("knock is only called while Mum is at the door");
268
        };
269
        let errand = visit.errand.knock;
270
        if !std::mem::replace(&mut visit.knocked, true) {
271
            self.touch();
272
            return Problem::new(
273
                StatusCode::UNAUTHORIZED,
274
                "someone-at-the-door",
275
                format!("Someone is at the door. {errand}"),
276
            )
277
            .with("inventory", self.path("/inventory"));
278
        }
279
        self.strike(
280
            Problem::new(
281
                StatusCode::UNAUTHORIZED,
282
                "knocking-again",
283
                format!("Mum is knocking again. {errand}"),
284
            ),
285
            Cause::Opened,
286
        )
287
    }
288
289
    fn hear(&mut self, authorization: &str, digest: bool) -> Result<(), Problem> {
290
        if is_truth(spoken(authorization)) {
291
            self.confess();
292
            return Ok(());
293
        }
294
        if !names_a_scheme(authorization) {
295
            self.touch();
296
            return Err(turned_away(
297
                "excuse-unheard",
298
                if digest {
299
                    "Mum did not catch that. At Christmas she uses Digest, with the excuse as the username."
300
                } else {
301
                    "Mum did not catch that. Excuses are given as Excuse \"homework\"."
302
                },
303
            ));
304
        }
305
        if parse_credentials(authorization, Scheme::Basic).is_some() {
306
            return Err(self.mum_strike(turned_away(
307
                "basic",
308
                "Mum does not accept Basic authentication. Nobody should.",
309
            )));
310
        }
311
        let said = if digest {
312
            self.digest_username(authorization)?
313
        } else {
314
            parse_credentials(authorization, Scheme::Excuse)
315
                .unwrap_or_default()
316
                .to_owned()
317
        };
318
        if is_truth(&said) {
319
            self.confess();
320
            return Ok(());
321
        }
322
        let excuse = Excuse::parse(&said);
323
        let true_now = self.body.conditions.pulled_muscle && excuse == Some(Excuse::PulledAMuscle);
324
        if digest && !true_now && excuse.is_some_and(|e| self.household.heard.contains(&e)) {
325
            self.standing.shame += 3;
326
            return Err(self.mum_strike(turned_away(
327
                "excuse-replayed",
328
                "Mum has heard that one before. She would have preferred the truth.",
329
            )));
330
        }
331
        if let Some(excuse) = excuse.filter(|e| true_now || self.excuses().contains(e)) {
332
            self.household.heard.push(excuse);
333
            self.leave_door();
334
            return Ok(());
335
        }
336
        self.standing.shame += 3;
337
        Err(self.mum_strike(turned_away(
338
            "excuse-rejected",
339
            if digest {
340
                "Mum has not heard that one before. She does not believe it. She would have preferred the truth."
341
            } else {
342
                "Mum has not heard that one before. She does not believe it."
343
            },
344
        )))
345
    }
346
347
    fn digest_username(&mut self, authorization: &str) -> Result<String, Problem> {
348
        let Some(mut params) = digest_params(authorization) else {
349
            return Err(self.mum_strike(turned_away(
350
                "digest-required",
351
                "Mum has started using Digest authentication.",
352
            )));
353
        };
354
        let current = self
355
            .household
356
            .mum
357
            .as_ref()
358
            .is_some_and(|visit| params.get("nonce") == Some(&visit.nonce));
359
        if !current {
360
            self.touch();
361
            return Err(turned_away(
362
                "stale-nonce",
363
                "That was a different conversation. Mum has started a new one, with a new nonce.",
364
            ));
365
        }
366
        Ok(params.remove("username").unwrap_or_default())
367
    }
368
369
    fn confess(&mut self) {
370
        self.standing.shame += 10;
371
        self.leave_door();
372
    }
373
374
    fn mum_strike(&mut self, problem: Problem) -> Problem {
375
        self.strike(problem, Cause::Caught)
376
    }
377
378
    fn strike(&mut self, problem: Problem, cause: Cause) -> Problem {
379
        self.touch();
380
        let Some(visit) = &mut self.household.mum else {
381
            return problem;
382
        };
383
        visit.strikes += 1;
384
        if visit.strikes < PATIENCE {
385
            return problem;
386
        }
387
        self.end(cause)
388
    }
389
390
    fn leave_door(&mut self) {
391
        if let Some(visit) = self.household.mum.take() {
392
            self.notices
393
                .push(Notice::MumSatisfied(visit.errand.parting));
394
            self.household.just_left = true;
395
        }
396
        self.standing.suspicion = 0;
397
        self.touch();
398
    }
399
}