Spaces:
Running
Running
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
|