atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
727d064fae3881054441752bc0a2afb71e1e63dd
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/household.rsRust319 lines9.8 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
    pub(crate) kitchen_tellings: u8,
105
}
106
107
pub struct MumVisit {
108
    pub(crate) errand: &'static str,
109
    pub(crate) nonce: String,
110
    pub(crate) strikes: u8,
111
    pub(crate) knocked: bool,
112
}
113
114
fn digest_params(authorization: &str) -> Option<HashMap<String, String>> {
115
    let (scheme, rest) = authorization.trim().split_once(' ')?;
116
    if !scheme.eq_ignore_ascii_case("digest") {
117
        return None;
118
    }
119
    Some(
120
        rest.split(',')
121
            .filter_map(|pair| {
122
                let (key, value) = pair.split_once('=')?;
123
                Some((
124
                    key.trim().to_ascii_lowercase(),
125
                    value.trim().trim_matches('"').to_owned(),
126
                ))
127
            })
128
            .collect(),
129
    )
130
}
131
132
fn spoken(authorization: &str) -> &str {
133
    authorization
134
        .split_once(' ')
135
        .map_or("", |(_, rest)| rest.trim().trim_matches('"'))
136
}
137
138
fn turned_away(code: &'static str, detail: &'static str) -> Problem {
139
    Problem::new(StatusCode::UNAUTHORIZED, code, detail)
140
}
141
142
impl Run {
143
    pub fn excuses(&self) -> Vec<Excuse> {
144
        if self.floor.number() < Floor::Nans.number() {
145
            return Excuse::EVERYDAY.to_vec();
146
        }
147
        Excuse::all()
148
            .filter(|excuse| !self.household.heard.contains(excuse))
149
            .collect()
150
    }
151
152
    pub(super) fn errand(&mut self, summoned: bool) -> &'static str {
153
        if summoned {
154
            return "She heard a noise.";
155
        }
156
        if self.online.filmed_at.is_some()
157
            && self.chance(
158
                self.decision("viral", "Has a neighbour already sent Mum the footage?"),
159
                VIRAL_ERRAND,
160
            )
161
        {
162
            return "A neighbour has sent her something.";
163
        }
164
        let errands = if self.floor == Floor::Nans {
165
            CHRISTMAS_ERRANDS
166
        } else {
167
            BEDROOM_ERRANDS
168
        };
169
        let weights = vec![1; errands.len()];
170
        let i = self.oracle.pick(
171
            self.decision("errand", "What has brought Mum to the door?"),
172
            &weights,
173
            &errands,
174
        );
175
        errands[i]
176
    }
177
178
    /// Mum answers every request on the run until she has been given something she believes.
179
    pub fn answer_door(&mut self, authorization: Option<&str>) -> Result<(), Problem> {
180
        let Some(visit) = &self.household.mum else {
181
            return Ok(());
182
        };
183
        let digest = self.floor == Floor::Nans;
184
        let challenge = if digest {
185
            format!("Digest realm=\"household\", nonce=\"{}\"", visit.nonce)
186
        } else {
187
            "Excuse realm=\"household\"".to_owned()
188
        };
189
        let result = match authorization {
190
            None => Err(self.knock()),
191
            Some(authorization) => self.hear(authorization, digest),
192
        };
193
        if self.ended.is_some() {
194
            return result;
195
        }
196
        result.map_err(|problem| problem.header(header::WWW_AUTHENTICATE, &challenge))
197
    }
198
199
    /// The first knock is free. Every one after it is a strike.
200
    fn knock(&mut self) -> Problem {
201
        let Some(visit) = &mut self.household.mum else {
202
            unreachable!("knock is only called while Mum is at the door");
203
        };
204
        let errand = visit.errand;
205
        if !std::mem::replace(&mut visit.knocked, true) {
206
            self.touch();
207
            return Problem::new(
208
                StatusCode::UNAUTHORIZED,
209
                "someone-at-the-door",
210
                format!("Someone is at the door. {errand}"),
211
            )
212
            .with("inventory", self.path("/inventory"));
213
        }
214
        self.strike(
215
            Problem::new(
216
                StatusCode::UNAUTHORIZED,
217
                "knocking-again",
218
                format!("Mum is knocking again. {errand}"),
219
            ),
220
            Cause::Opened,
221
        )
222
    }
223
224
    fn hear(&mut self, authorization: &str, digest: bool) -> Result<(), Problem> {
225
        if is_truth(spoken(authorization)) {
226
            self.confess();
227
            return Ok(());
228
        }
229
        if parse_credentials(authorization, Scheme::Basic).is_some() {
230
            return Err(self.mum_strike(turned_away(
231
                "basic",
232
                "Mum does not accept Basic authentication. Nobody should.",
233
            )));
234
        }
235
        let said = if digest {
236
            self.digest_username(authorization)?
237
        } else {
238
            parse_credentials(authorization, Scheme::Excuse)
239
                .unwrap_or_default()
240
                .to_owned()
241
        };
242
        if is_truth(&said) {
243
            self.confess();
244
            return Ok(());
245
        }
246
        let excuse = Excuse::parse(&said);
247
        let true_now = self.body.conditions.pulled_muscle && excuse == Some(Excuse::PulledAMuscle);
248
        if digest && !true_now && excuse.is_some_and(|e| self.household.heard.contains(&e)) {
249
            self.standing.shame += 3;
250
            return Err(self.mum_strike(turned_away(
251
                "excuse-replayed",
252
                "Mum has heard that one before. She would have preferred the truth.",
253
            )));
254
        }
255
        if let Some(excuse) = excuse.filter(|e| true_now || self.excuses().contains(e)) {
256
            self.household.heard.push(excuse);
257
            self.leave_door();
258
            return Ok(());
259
        }
260
        self.standing.shame += 3;
261
        Err(self.mum_strike(turned_away(
262
            "excuse-rejected",
263
            if digest {
264
                "Mum has not heard that one before. She does not believe it. She would have preferred the truth."
265
            } else {
266
                "Mum has not heard that one before. She does not believe it."
267
            },
268
        )))
269
    }
270
271
    fn digest_username(&mut self, authorization: &str) -> Result<String, Problem> {
272
        let Some(mut params) = digest_params(authorization) else {
273
            return Err(self.mum_strike(turned_away(
274
                "digest-required",
275
                "Mum has started using Digest authentication.",
276
            )));
277
        };
278
        let current = self
279
            .household
280
            .mum
281
            .as_ref()
282
            .is_some_and(|visit| params.get("nonce") == Some(&visit.nonce));
283
        if !current {
284
            return Err(self.mum_strike(turned_away(
285
                "stale-nonce",
286
                "That was a different conversation.",
287
            )));
288
        }
289
        Ok(params.remove("username").unwrap_or_default())
290
    }
291
292
    fn confess(&mut self) {
293
        self.standing.shame += 10;
294
        self.leave_door();
295
    }
296
297
    fn mum_strike(&mut self, problem: Problem) -> Problem {
298
        self.strike(problem, Cause::Caught)
299
    }
300
301
    fn strike(&mut self, problem: Problem, cause: Cause) -> Problem {
302
        self.touch();
303
        let Some(visit) = &mut self.household.mum else {
304
            return problem;
305
        };
306
        visit.strikes += 1;
307
        if visit.strikes < PATIENCE {
308
            return problem;
309
        }
310
        self.end(cause)
311
    }
312
313
    fn leave_door(&mut self) {
314
        self.household.mum = None;
315
        self.standing.suspicion = 0;
316
        self.notices.push(Notice::MumSatisfied);
317
        self.touch();
318
    }
319
}