File size: 4,580 Bytes
125f9c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c42a02
 
 
 
 
 
 
 
 
125f9c0
 
5c42a02
 
125f9c0
5c42a02
125f9c0
 
5c42a02
 
125f9c0
 
 
5c42a02
 
125f9c0
 
 
 
 
5c42a02
 
 
 
 
 
 
 
125f9c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
102
103
104
105
"""The Outcome Oracle's spine β€” CODE-owned, deterministic, model-free.

The duel's verdict: the model must NEVER decide whether a gamble succeeds, or the
roguelike isn't fair and the "why small models" story is decorative. So pass/fail
and every reward magnitude are computed here from a seeded RNG + the TIER_TABLE.
The router model only writes the *flavor sentence* for an outcome already decided.

Deterministic by (seed, turn): the same dream seed always replays the same dice,
which is what makes the Dream Journal card's "beat my run: seed abc123" honest.
"""
from __future__ import annotations
import random
from dataclasses import dataclass

from .world import TIER_TABLE, WorldState, COURAGE_MAX


@dataclass
class Outcome:
    tier: str
    result: str            # "success" | "partial" | "fail"
    roll: int              # 1..100, shown as the visible die
    fail_threshold: int    # roll <= this = fail; shown on the HUD
    lucidity_cost: int
    progress_reward: int
    courage_gain: int
    menace_delta: int

    @property
    def won_roll(self) -> bool:
        return self.result != "fail"


def _rng(seed: str, turn: int) -> random.Random:
    """Stable per-(seed, turn) stream β€” no global Math.random/Date dependence."""
    return random.Random(f"{seed}:{turn}")


def resolve(tier: str, seed: str, turn: int) -> Outcome:
    """Roll one gambit. Pure: identical (tier, seed, turn) -> identical Outcome."""
    spec = TIER_TABLE.get(tier, TIER_TABLE["safe"])
    rng = _rng(seed, turn)
    roll = rng.randint(1, 100)
    fail_threshold = int(spec["fail_chance"] * 100)

    if roll <= fail_threshold:
        result = "fail"
    elif roll <= fail_threshold + 20:        # a 20-pt band above the line = grazed it
        result = "partial"
    else:
        result = "success"

    # Reward math, all code-owned. KEY DESIGN: the story ALWAYS proceeds toward the
    # goal β€” every gambit gives *some* progress, so the dream never stalls. The dice
    # decides the COST, not whether anything happens:
    #   success β†’ big leap forward, the Nightmare eases, Hobbes grows braver
    #   partial β†’ solid step forward, small cost
    #   fail    β†’ you still stumble forward, BUT the Nightmare surges (extra lucidity
    #             drain + menace) β€” that's the real, legible risk of a bold bet.
    reward = int(spec["progress_reward"])
    cost = int(spec["lucidity_cost"])
    if result == "fail":
        return Outcome(tier, result, roll, fail_threshold,
                       lucidity_cost=cost + 1,                 # the dream destabilizes
                       progress_reward=max(3, reward // 3),    # still inch forward
                       courage_gain=0,
                       menace_delta=int(spec["menace_delta"]) + 2)  # Nightmare surges
    if result == "partial":
        return Outcome(tier, result, roll, fail_threshold,
                       lucidity_cost=cost,
                       progress_reward=(reward * 2) // 3,
                       courage_gain=0,
                       menace_delta=int(spec["menace_delta"]))
    return Outcome(tier, result, roll, fail_threshold,
                   lucidity_cost=cost,
                   progress_reward=reward,
                   courage_gain=int(spec["courage_gain"]),
                   menace_delta=max(0, int(spec["menace_delta"]) - 1))  # a win eases pressure


def apply(state: WorldState, out: Outcome) -> None:
    """Mutate world-state by an Outcome β€” the ONLY place gamble math touches state.

    One clean tension: PROGRESS climbs to 100 (wake with the prize) while LUCIDITY
    drains to 0 (lost in the dream). Every gambit advances progress; bolder gambits
    leap further but cost more lucidity, and a fail costs extra. Menace is the
    Nightmare's narrative pressure (drives the Nightmare beat), not a hidden drain β€”
    so the only way to lose stays legible: run out of lucidity.
    """
    state.lucidity = max(0, state.lucidity - out.lucidity_cost)
    state.progress = min(100, state.progress + out.progress_reward)
    state.courage = min(COURAGE_MAX, state.courage + out.courage_gain)
    state.menace = max(0, state.menace + out.menace_delta)
    state.peak_menace = max(state.peak_menace, state.menace)

    if out.result == "fail":
        state.scars.append(f"a {out.tier} bet gone wrong (turn {state.turn})")

    if state.progress >= 100:
        # Reaching the prize on the same beat that drains you = out just in time.
        state.complete = True
        state.progress = 100
    elif state.lucidity <= 0:
        state.lost = True