File size: 4,309 Bytes
c9c35b3
 
161d0a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93a3c54
161d0a9
 
 
 
 
 
 
 
 
 
 
 
93a3c54
 
 
 
 
 
 
 
 
 
 
 
 
fc6ce84
 
 
 
 
 
 
 
 
d67855c
fc6ce84
d67855c
03b1c9d
 
 
 
 
 
 
 
161d0a9
 
 
 
4a3304a
 
 
161d0a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 alvations (Melon Lab)
"""Tracks how the player behaves across loops.

The point of this module is not scoring. It is to give the game a small
model of the player's habits so the narration can quietly react to them:
the corridor that "remembers you."
"""

from __future__ import annotations

from dataclasses import dataclass, field, asdict
from typing import Dict, Optional


@dataclass
class PlayerMemory:
    # progress
    level: int = 0            # which hallway you are standing in (0 = the start)
    best_level: int = 0       # furthest you have ever reached
    loops: int = 0            # total corridors walked this run
    attempts: int = 1         # which run this is (1 = first try; +1 on each reset)

    # behaviour
    turn_backs: int = 0
    continues: int = 0
    false_reports: int = 0    # turned back when nothing was wrong
    missed: int = 0           # walked on past a real change
    inspects: Dict[str, int] = field(default_factory=dict)

    # confidence
    confidence_sum: float = 0.0
    confidence_n: int = 0

    # hard-mode continuity: what changed last loop, so an anomaly can persist or
    # revert across loops (a memory test that spans more than one loop).
    prev_anomaly_prop: Optional[str] = None
    prev_anomaly_val: Optional[str] = None

    # coach arc: how many times the passenger (the NPC) and the player have
    # engaged each other. Once it crosses a threshold the passenger delivers its
    # one fixed utterance (npc_triggered), which *arms* the false way out; only
    # then can the false exit surface, near the end (possibly a later loop than
    # the utterance itself).
    seen_by_npc: int = 0
    npc_triggered: bool = False

    # aggro-item (late-revealed detail): one or two small, inconspicuous
    # properties can be held out of the early loops and only start appearing
    # later in the run, so the player meets them for the first time deep in a
    # climb and the stakes rise. `held` maps each held property to the loop level
    # at which it starts showing. seen_props is the fairness guard: it records
    # every property the player has actually been shown in a *prior* loop, and an
    # anomaly is only ever placed on a baseline-seen property, so a late reveal
    # can never be the anomaly on the very loop it first appears (that would be
    # pure luck, not memory).
    seen_props: list = field(default_factory=list)
    held: dict = field(default_factory=dict)   # held prop -> reveal level

    # "Insane" difficulty only: the baseline value chosen for each property THIS
    # climb (maps prop -> value key). Insane randomizes the baseline per run from
    # a property's existing value pools, so a wording that is the norm in one run
    # is the change in another; a bot that memorized the global vocabulary cannot
    # tell clean from changed and must remember *this* run's baseline. Empty for
    # every other difficulty (they use the arc's fixed baseline).
    run_baseline: dict = field(default_factory=dict)

    def record_inspect(self, thing: str) -> None:
        self.inspects[thing] = self.inspects.get(thing, 0) + 1

    def record_confidence(self, value: Optional[int]) -> None:
        # Only count a real number: a malformed body ({"confidence": "5"} or a
        # list) must not reach the arithmetic below and raise a TypeError.
        if isinstance(value, (int, float)) and not isinstance(value, bool) and value:
            self.confidence_sum += value
            self.confidence_n += 1

    @property
    def confidence(self) -> float:
        if not self.confidence_n:
            return 0.0
        return round(self.confidence_sum / self.confidence_n, 2)

    @property
    def favorite(self) -> Optional[str]:
        """The property the player fixates on, once a habit has formed."""
        if not self.inspects:
            return None
        thing, count = max(self.inspects.items(), key=lambda kv: kv[1])
        return thing if count >= 3 else None

    def to_dict(self) -> dict:
        return asdict(self)

    @classmethod
    def from_dict(cls, data: dict) -> "PlayerMemory":
        if not data:
            return cls()
        known = {f: data[f] for f in cls.__dataclass_fields__ if f in data}
        return cls(**known)