atagen

jelq

public

an http based roguelike rpg about jelqing

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