-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhallway.py
More file actions
623 lines (558 loc) · 30.9 KB
/
Copy pathhallway.py
File metadata and controls
623 lines (558 loc) · 30.9 KB
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
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 alvations (Melon Lab)
"""The corridor: building a loop and describing it.
A "room" is not a paragraph. It is a set of properties, each currently at its
baseline value or (at most one of them) at an anomalous value. The describer
turns that into a handful of sentences drawn from rotating pools, so the same
place reads differently every time while still being the same place.
Content is organised into *arcs* (skins/backstories) under data/arcs/. Each arc
file shares the same schema; only the theme, story, and vocabulary change.
"""
from __future__ import annotations
import glob
import json
import os
import random
from typing import Dict, List, Optional
import anomalies
import i18n
from memory import PlayerMemory
ARCS_DIR = os.path.join(os.path.dirname(__file__), "data", "arcs")
DEFAULT_ARC = "hallway-eight"
# How many hallways/landings you must clear. Reaching this level wins.
# Canonically 8 (the game's whole identity). H8_GOAL overrides it only for a
# non-canonical *shortcut* test build (e.g. 3 stages) so the end screen can be
# reached quickly; clamped to 1..8. Do not use a shortcut build as canonical.
GOAL = max(1, min(8, int(os.environ.get("H8_GOAL", "8"))))
# How many details are described each loop. Enough to overload memory a little,
# few enough to keep it readable.
MIN_DETAILS = 4
MAX_DETAILS = 6
# Chance a loop contains an anomaly, as a function of how far along you are.
# The opening loop (level 0) is deliberately *very* low: on your first loop you
# have no baseline yet, so being turned back is close to a coin flip you cannot
# win. It is not zero (a rare early jolt keeps the place honest), just ~2% on
# normal, and it ramps up steeply from there to a near coin-flip near the exit.
ANOMALY_BASE = 0.02 # at level 0 (the opening loop, no baseline yet)
ANOMALY_STEP = 0.076 # added per level cleared (steep, to reach the cap by ~L7)
ANOMALY_MAX = 0.55 # never so high that "always turn back" wins
# Three difficulties scale the same ramp, all sharing the very-low opening. Easy
# ramps gentler to a lower cap; normal is the canonical ramp above; hard ramps
# stiffer to a higher cap AND adds new *kinds* of change (see build(): double
# changes and cross-loop persistence). No level-0 floor: the low opening is the
# point, and levels 1+ ramp well clear of zero so "always continue" still loses.
# "insane" is a server-only, UNEXPOSED difficulty (the client offers only
# easy/normal/hard, like the hidden locales). Its defining feature is not the
# ramp but a per-run randomized baseline (see build()), which defeats a bot that
# memorizes the fixed all-clear vocabulary. It reuses existing translated content,
# so it needs no new i18n. Keep it out of the client picker until deliberately
# released.
DIFFICULTIES = ("easy", "normal", "hard", "insane")
EXPOSED_DIFFICULTIES = ("easy", "normal", "hard")
DEFAULT_DIFFICULTY = "normal"
# The NPC's loop-aware acknowledgement values (the twist): a narrative climax, not
# a resting state, so never an insane baseline.
_NPC_ACK_VALUES = ("knows", "speaks")
# Prose markers that make an anomaly value unfit as an insane BASELINE: it would
# announce a change (or reference the player's memory / a prior state) while being
# shown as this run's calm normal. Matched case-insensitively against every
# sentence of the value's pool. Lean toward over-excluding: a borderline value
# just falls back to the calm baseline, which is always safe.
_INSANE_BASELINE_UNSAFE = (
"remember", " now", "now,", "no longer", "used to", "a moment ago", "moment ago",
"than you", "than it", "than they", "than he", "than before", "than the", "never",
" was ", " were ", "has stopped", "has gone", "has moved", "has reversed",
"has turned", "has appeared", "has spilled", "have gone", "have been",
"have moved", "been prised", "not the", "not a ", "not 8", "not one",
"where there", "wrong way", "unannounced", "behind you", "again?", "don't belong",
"don't remember", "climbing now", "coming up", "should ", "should be",
"different colour", "wasn't there", "gone cold", "gone silent", "gone black",
"gone out", "stopped", "moved to", "awake now", "empty now", "much closer",
"prised off", "up full", "faster than", "slowing", "raised a hand",
"lifts a hand", "looks up", "waiting for you", "turned", "is gone", "is missing",
"half-second",
)
_DIFF = {
"easy": {"base": 0.01, "step": 0.042, "max": 0.30, "floor": 0.0},
"normal": {"base": ANOMALY_BASE, "step": ANOMALY_STEP, "max": ANOMALY_MAX, "floor": 0.0},
"hard": {"base": 0.03, "step": 0.081, "max": 0.60, "floor": 0.0},
# Insane is strictly the hardest: a clearly higher anomaly ramp than hard, AND
# it inherits every hard escalation (aggro holds, cross-loop persist/revert,
# the deep double-change) on top of its per-run randomized baseline.
"insane": {"base": 0.03, "step": 0.092, "max": 0.66, "floor": 0.0},
}
def norm_difficulty(d: Optional[str]) -> str:
# Guard against a non-string (e.g. a malformed JSON body {"difficulty": []}):
# `x in _DIFF` would raise on an unhashable value.
return d if isinstance(d, str) and d in _DIFF else DEFAULT_DIFFICULTY
def anomaly_chance(level: int, difficulty: str = DEFAULT_DIFFICULTY) -> float:
p = _DIFF[norm_difficulty(difficulty)]
c = min(p["max"], p["base"] + p["step"] * max(0, level))
return max(p["floor"], c)
def _arc_path(arc_id: str) -> str:
return os.path.join(ARCS_DIR, f"{arc_id}.json")
def list_arcs() -> List[dict]:
"""Return the selectable arcs, described by their meta block.
The default arc is listed first; the rest follow alphabetically.
"""
arcs = []
for path in sorted(glob.glob(os.path.join(ARCS_DIR, "*.json"))):
with open(path, "r", encoding="utf-8") as f:
meta = json.load(f).get("meta", {})
if meta.get("id"):
arcs.append({
"id": meta["id"],
"title": meta.get("title", meta["id"]),
"tagline": meta.get("tagline", ""),
"skin": meta.get("skin", "hallway"),
})
arcs.sort(key=lambda a: (a["id"] != DEFAULT_ARC, a["id"]))
return arcs
# --- Act 2 (touch-the-room) trigger tuning ------------------------------------
# See docs/ACT2.md. Weights scale with GOAL so they hold on a shortcut build.
# H8_ACT2_TEST boosts the fold and surfaces the false exit far more often, so the
# endings can be reached quickly for testing; it must stay OFF for the real game.
ACT2_TEST = os.environ.get("H8_ACT2_TEST", "").strip().lower() not in (
"", "0", "false", "no",
)
def act2_fold_chance(level: int, goal: int) -> float:
"""Chance a touch folds the run back to level 0. None before the onset."""
if ACT2_TEST:
return 0.30 if level >= 1 else 0.0
onset = max(2, round(goal * 0.35)) # ~level 3 at goal 8
if level < onset:
return 0.0
return 0.15 if level >= round(goal * 0.75) else 0.10
def act2_exit_ready(level: int, goal: int, rng: random.Random,
difficulty: str = DEFAULT_DIFFICULTY) -> bool:
"""Whether the rare 'false way out' surfaces this loop. Gated to the
three-quarter mark (not the finish), or boosted under test. Easy mode makes
it much rarer, so a younger player does not fall into the wrong ending."""
if ACT2_TEST:
return level >= 1 and rng.random() < 0.6
lo = max(1, round(goal * 0.6)) # ~level 5 at goal 8
hi = goal - 2 # ~level 6 at goal 8 (not the last)
# Easy: about a 1-in-100 chance to even be offered, so a younger player
# almost never trips into the wrong ending.
chance = 0.01 if difficulty == "easy" else 0.07
return lo <= level <= hi and rng.random() < chance
class Hallway:
def __init__(self, arc_id: str = DEFAULT_ARC):
path = _arc_path(arc_id)
if not os.path.exists(path):
path = _arc_path(DEFAULT_ARC)
with open(path, "r", encoding="utf-8") as f:
self.data = json.load(f)
self.properties: Dict[str, dict] = self.data["properties"]
self.meta: dict = self.data.get("meta", {})
self.labels: Dict[str, str] = self.meta.get("labels", {})
self.arc_id: str = self.meta.get("id", "")
self.act2: dict = self.data.get("act2", {})
# The property that carries this arc's NPC (used for the loop-aware
# acknowledgement and, on the coach, the "seen enough" exit trail).
self.npc_prop: Optional[str] = self.meta.get("npc_prop")
def _loc(self, locale: Optional[str], key: str, fallback: str) -> str:
return i18n.localize(locale, f"a:{self.arc_id}|{key}", fallback)
def _exit_ready(self, mem: PlayerMemory, rng: random.Random,
difficulty: str) -> bool:
"""Whether the false way out surfaces this loop.
The coach is special: rather than a flat late-game roll, its passenger
has to have *clocked the player enough* first, and the offer only comes
near the very end. The accumulating sightings are the faint trail that
makes the alternate ending discoverable instead of lottery-rare. The
other arcs keep the plain gated roll.
"""
if self.arc_id == "coach8":
# Armed only after the passenger's fixed utterance has fired, and
# only near the end. The utterance may have landed several loops ago.
if not mem.npc_triggered:
return False
if ACT2_TEST:
return mem.level >= 1 and rng.random() < 0.6
near_end = mem.level >= GOAL - 2 # levels 6-7 at goal 8
# About 1-in-100 in easy (on top of the trigger + near-end gates), so
# a younger player almost never reaches the wrong ending, even though
# the passenger's trail is still visible to them.
roll = 0.03 if difficulty == "easy" else 0.5
return near_end and rng.random() < roll
return act2_exit_ready(mem.level, GOAL, rng, difficulty)
@staticmethod
def _value_keys(spec: dict) -> List[str]:
"""Every value a property can take: its baseline value pools plus its
anomaly value pools. Used to enumerate the full value set."""
return list(spec.get("values", {})) + list(spec.get("anomalies", {}))
@staticmethod
def _insane_baseline_keys(spec: dict, prop: str, npc_prop: Optional[str]) -> List[str]:
"""The values 'insane' may use as this run's baseline for a property.
Insane randomizes the baseline from the full value set, but an anomaly
value whose prose ANNOUNCES a change (or the NPC's acknowledgement) reads
as wrong when shown as a calm baseline: "the arrow has reversed" on a clean
loop, then a fair turn-back gets reset. So the baseline pool is the calm
`values` (always safe) plus only those anomaly values whose every sentence
reads as a plain state (no memory/change/comparison marker), and never the
NPC twist. Each property keeps at least its calm value, so the pool is
never empty; enough properties still randomize to keep insane anti-bot.
The CHANGE is still drawn from the full set (a change SHOULD announce one).
"""
keys = list(spec.get("values", {}))
for val, pool in spec.get("anomalies", {}).items():
if prop == npc_prop and val in _NPC_ACK_VALUES:
continue
if any(m in s.lower() for s in pool for m in _INSANE_BASELINE_UNSAFE):
continue
keys.append(val)
return keys
@staticmethod
def _pool_and_kind(spec: dict, val: str):
"""The sentence pool and i18n kind for ANY value of a property. A value
in `values` is a calm baseline line (kind 'v'); one in `anomalies` is a
wrongness line (kind 'x'). This lets a value serve as a run-baseline OR a
change depending on the difficulty, while always keying to the SAME
already-translated line, so 'insane' needs no new i18n."""
if val in spec.get("values", {}):
return spec["values"][val], "v"
return spec["anomalies"][val], "x"
def build(self, mem: PlayerMemory, rng: random.Random,
locale: Optional[str] = None,
difficulty: str = DEFAULT_DIFFICULTY) -> dict:
"""Generate the next place for the player's current level.
The probability of an anomaly ramps with the player's level: gentle at
the first stage (so they can learn the baseline by proceeding), rising
toward a coin-flip so that neither "always continue" nor "always turn
back" becomes a winning strategy.
`locale` picks which language the prose is rendered in; each line falls
back to its English original when a translation is missing.
"""
diff = norm_difficulty(difficulty)
# --- Aggro-item: hold small details out of the early loops ------------
# A short list of inconspicuous "late" properties can be held back so
# they only start appearing later in the run, meeting the player for the
# first time deep in a climb (the stakes rise once a detail you never had
# to track suddenly matters). Decided once per climb (at level 0).
# - HARD: always holds 1 or 2 of them, revealing from ~level 2 onward.
# This is where the "not everything is shown up front" pressure lives.
# - NORMAL: only *rarely* holds a single one, so a normal player gets a
# fair chance to meet almost every encounter; the run-to-run rotation
# of which details show (never all of them at once) is the everyday
# replay hook, not a deliberate hold.
# - EASY: never holds.
# Each held item is paced to reveal before the last level, and can never
# be the change on the loop it first appears (see the exclusion below),
# so a late arrival never turns the finish into pure luck.
late_props = [p for p in self.meta.get("late_props", [])
if p in self.properties]
if mem.level == 0:
mem.seen_props = []
mem.held = {}
mem.run_baseline = {}
if diff in ("hard", "insane") and late_props:
hold_k = 1 if len(late_props) < 2 else rng.randint(1, 2)
elif diff == "normal" and late_props and rng.random() < 0.15:
hold_k = 1
else:
hold_k = 0
if hold_k:
lo = min(2, GOAL - 2) # from ~level 2 onward
hi = max(lo, GOAL - 2) # up to ~level 6 (never the last)
for p in rng.sample(late_props, hold_k):
mem.held[p] = rng.randint(lo, hi)
# INSANE only: randomize this climb's baseline value for every
# property (from its full set of value pools). The "normal" state is
# then run-specific, so a bot that memorized the global all-clear
# vocabulary cannot tell clean from changed and must remember THIS
# run's baseline. Reuses existing lines, so no new content or i18n.
if diff == "insane":
for p, spec in self.properties.items():
# Draw from the baseline-SAFE pool (calm value + descriptive
# anomalies), never a value whose prose announces a change, so a
# clean loop never reads as wrong. See _insane_baseline_keys.
keys = self._insane_baseline_keys(spec, p, self.npc_prop)
if keys:
mem.run_baseline[p] = rng.choice(keys)
# Props still held out of this loop (they reveal on a later loop).
held_now = {p for p, rv in mem.held.items() if mem.level < rv}
# An anomaly may not land on a held item until the player has seen it at
# baseline on an earlier loop (recorded in seen_props). That covers both
# "still held" and "revealed this very loop", so a held item's first
# appearance is always a calm baseline, never the trap.
excluded_anomaly = {p for p in mem.held if p not in mem.seen_props}
# INSANE fairness: because the baseline is randomized per run, a property's
# "normal" is only knowable once the player has seen it this run. So an
# anomaly may only land on a property already shown at baseline on an
# earlier loop (exactly the aggro-item guard, extended to every property).
# This makes the opening loop always a clean baseline and every property's
# first appearance a calm baseline, never the trap.
if diff == "insane":
excluded_anomaly = excluded_anomaly | {
p for p in self.properties if p not in mem.seen_props}
# The opening loop (level 0) uses the very-low base above, so a fresh run
# is almost always a clean baseline before anything is allowed to move.
has_anomaly = rng.random() < anomaly_chance(mem.level, diff)
# Every property that has changed this loop -> its anomalous value.
# Normally 0 or 1. Hard mode escalates in *kind*: cross-loop persistence
# or revert (below), and, deep in a run, a *second* simultaneous change.
# The double is made fair by a post-hoc "flare" on a correct turn-back
# (the two changed lines pulse), so the "keep scanning" lesson can form.
changed: Dict[str, str] = {}
# Hard mode, cross-loop continuity: after a change, the same anomaly can
# PERSIST (you caught it, but is it still there?) or the loop can be a
# deliberate clean REVERT (back to baseline right after a change). Both
# break the "each loop is independent" assumption and test memory across
# more than one loop. This is the whole of hard mode's escalation.
if mem.level > 0 and diff in ("hard", "insane") and mem.prev_anomaly_prop:
r = rng.random()
spec = self.properties.get(mem.prev_anomaly_prop, {})
if diff == "insane":
# In insane a change is any value != this run's baseline (it may be
# a calm value too), so validate against the full value set.
valid = (mem.prev_anomaly_val in self._value_keys(spec)
and mem.prev_anomaly_val
!= mem.run_baseline.get(mem.prev_anomaly_prop))
else:
valid = mem.prev_anomaly_val in spec.get("anomalies", {})
if r < 0.22 and valid:
has_anomaly = True
changed[mem.prev_anomaly_prop] = mem.prev_anomaly_val
elif r < 0.34:
has_anomaly = False # revert: a clean loop right after a change
if has_anomaly and not changed:
picked = anomalies.pick_anomaly(
self.properties, rng, excluded_anomaly,
baseline_of=(mem.run_baseline if diff == "insane" else None))
if picked:
changed[picked[0]] = picked[1]
# Hard mode, double change: deep in the run, a second (distinct) detail
# can move at the same time. Excluding the already-chosen change (and any
# held item) makes the second detail distinct, so the two never collide.
# The fairness guard shows both, and a correct turn-back flares both lines
# afterward so the player learns to scan.
if (diff in ("hard", "insane") and changed and len(changed) == 1
and mem.level >= round(GOAL * 0.5) and rng.random() < 0.30):
second = anomalies.pick_anomaly(
self.properties, rng, excluded_anomaly | set(changed),
baseline_of=(mem.run_baseline if diff == "insane" else None))
if second:
changed[second[0]] = second[1]
has_anomaly = bool(changed)
anomaly_props = list(changed.keys())
# The primary change (for the NPC acknowledgement and continuity): prefer
# the NPC property if it is one of the changed ones.
primary = None
if self.npc_prop and self.npc_prop in changed:
primary = self.npc_prop
elif anomaly_props:
primary = anomaly_props[0]
# Decide which properties get described this loop.
order = list(self.data["anchor_order"])
if held_now:
# Held aggro-items stay out of the running until their reveal loop.
order = [p for p in order if p not in held_now]
rng.shuffle(order)
n = rng.randint(MIN_DETAILS, MAX_DETAILS)
shown = order[:n]
# Every changed property must be visible this loop -- otherwise the
# player is asked to notice something they were never shown.
for ap in anomaly_props:
if ap not in shown:
spare = [s for s in shown if s not in anomaly_props]
if spare:
shown[shown.index(rng.choice(spare))] = ap
else:
shown.append(ap)
# On its reveal loop, make sure a just-revealed held item actually
# appears (at baseline, since it is still excluded from the anomaly), so
# the player registers it before it can ever be the change on a later loop.
for hp in [p for p in mem.held
if p not in held_now and p not in mem.seen_props]:
if hp not in shown:
spare = [s for s in shown if s not in anomaly_props]
if spare:
shown[shown.index(rng.choice(spare))] = hp
else:
shown.append(hp)
# Re-sort shown into the canonical order so the "camera" of the prose
# feels consistent even as content rotates.
shown = [p for p in self.data["anchor_order"] if p in shown]
# Record every property shown this loop, so the anomaly can only ever
# land on a detail the player has already seen at baseline (the fairness
# guard the aggro-item reveal depends on).
for p in shown:
if p not in mem.seen_props:
mem.seen_props.append(p)
# The coach's passenger clocks the player when it actually *interacts* --
# i.e. it is the change this loop (it turns, it acknowledges you), not
# merely present. Enough such interactions (plus the player engaging it
# back, see /api/interact) eventually make it deliver its one fixed
# utterance, which arms the false way out (see _exit_ready).
if self.npc_prop and primary == self.npc_prop:
mem.seen_by_npc += 1
# The arming utterance: fires once, on the first loop after enough
# acquaintance. It may land well before the loop the false exit finally
# surfaces on, or on the same one.
npc_utterance = None
_utter_at = 1 if ACT2_TEST else 3
if (self.arc_id == "coach8" and not mem.npc_triggered
and mem.seen_by_npc >= _utter_at):
mem.npc_triggered = True
npc_utterance = self._loc(locale, "act2|exit|utterance",
self.act2.get("exit", {}).get("utterance", ""))
sign_text = anomalies.drift_sign(self.data["sign_variants"], rng)
sentences: List[str] = []
keys: List[str] = []
for prop in shown:
spec = self.properties[prop]
if prop in changed:
val = changed[prop]
pool, kind = self._pool_and_kind(spec, val)
elif diff == "insane":
# This run's randomized baseline for the property (a calm 'v' line
# or, when the run-baseline happens to be a wrongness value, its
# 'x' line, keyed to the same existing translation).
val = mem.run_baseline.get(prop, spec["baseline"])
pool, kind = self._pool_and_kind(spec, val)
else:
val, kind = spec["baseline"], "v"
pool = spec["values"][val]
idx = rng.randrange(len(pool))
skey = f"p:{prop}|{kind}:{val}|{idx}"
text = self._loc(locale, skey, pool[idx])
sentences.append(anomalies.render_sentence(text, sign_text))
keys.append(f"a:{self.arc_id}|{skey}")
# Remember this loop's primary change so hard mode can persist/revert it.
mem.prev_anomaly_prop = primary
mem.prev_anomaly_val = changed.get(primary) if primary else None
anomaly_prop = primary
anomaly_val = changed.get(primary) if primary else None
# Adaptive touch: if the player keeps inspecting one thing, and it is
# on show this loop, let the place notice.
fav = mem.favorite
adaptive_line = None
if fav and fav in shown and rng.random() < 0.5:
pool = self.data["adaptive"]["repeat_inspect"]
idx = rng.randrange(len(pool))
tmpl = self._loc(locale, f"adaptive|repeat_inspect|{idx}", pool[idx])
thing = self._loc(locale, f"label|{fav}", self.labels.get(fav, fav))
adaptive_line = tmpl.replace("{THING}", thing)
heading = anomalies.drift_heading(
self.data["title_variants"], mem.loops, rng
)
head_key = None
try:
hidx = self.data["title_variants"].index(heading)
head_key = f"a:{self.arc_id}|title|{hidx}"
heading = self._loc(locale, f"title|{hidx}", heading)
except ValueError:
pass
# Act 2: which shown details can be touched this loop, and (rarely) the
# false way out. Rolled last so it never perturbs the loop's own prose.
touch = []
for prop in shown:
spec = self.act2.get("touch", {}).get(prop)
if spec:
touch.append({
"prop": prop,
"verb": self._loc(locale, f"act2|t:{prop}|verb",
spec.get("verb", "")),
})
exit_offer = None
ex = self.act2.get("exit")
if ex and self._exit_ready(mem, rng, diff):
exit_offer = {
"tempt": self._loc(locale, "act2|exit|tempt", ex.get("tempt", "")),
"action": self._loc(locale, "act2|exit|action", ex.get("action", "")),
}
return {
"heading": heading,
"sentences": sentences,
"adaptive": adaptive_line,
"sign_text": sign_text,
"shown": shown,
"touch": touch,
"exit_offer": exit_offer,
"npc_utterance": npc_utterance, # coach: the one fixed arming line
# the audit keys that compose this level (for level-QE); stripped
# from the public payload like any other underscore field.
"_keys": ([head_key] if head_key else []) + keys,
# server-authoritative truth, never sent to the client:
"_has_anomaly": has_anomaly,
"_anomaly_prop": anomaly_prop,
"_anomaly_val": anomaly_val,
"_anomaly_props": anomaly_props,
}
def public(self, room: dict) -> dict:
"""Strip the answer before sending a room to the browser."""
return {k: v for k, v in room.items() if not k.startswith("_")}
def meta_for(self, locale: Optional[str] = None) -> dict:
"""The arc meta with its display strings localized for the payload."""
m = dict(self.meta)
m["title"] = self._loc(locale, "title_main", m.get("title", ""))
m["tagline"] = self._loc(locale, "tagline", m.get("tagline", ""))
m["go_on"] = self._loc(locale, "go_on", m.get("go_on", ""))
m["turn_back"] = self._loc(locale, "turn_back", m.get("turn_back", ""))
m["progress"] = self._loc(locale, "progress", m.get("progress", ""))
m["share_prompt"] = self._loc(locale, "share_prompt", m.get("share_prompt", ""))
return m
def interact(self, prop: str, mem: PlayerMemory, rng: random.Random,
locale: Optional[str], goal: int) -> Optional[dict]:
"""Resolve a touch on a shown detail into one flavour outcome. Returns
{kind, line} for flashback / reaction / herring / fold, or None if the
detail is not touchable. NEVER reads the anomaly, so it cannot be a
detector, and never changes the go-on/turn-back judgement."""
spec = self.act2.get("touch", {}).get(prop)
if not spec:
return None
if spec.get("fold") and rng.random() < act2_fold_chance(mem.level, goal):
kind = "fold"
else:
choices = []
if self.act2.get("flashbacks"):
choices.append("flashback")
if spec.get("reaction"):
choices.append("reaction")
if spec.get("herring"):
choices.append("herring")
if not choices:
return None
kind = rng.choice(choices)
if kind == "flashback":
pool = self.act2.get("flashbacks", [])
idx = rng.randrange(len(pool))
line = self._loc(locale, f"act2|flashback|{idx}", pool[idx])
else:
pool = spec.get(kind, [])
idx = rng.randrange(len(pool))
line = self._loc(locale, f"act2|t:{prop}|{kind}|{idx}", pool[idx])
# idx lets the client collect flashbacks (which fragment was seen).
return {"kind": kind, "line": line, "idx": idx}
def take_exit(self, locale: Optional[str] = None) -> List[str]:
"""The alternate-ending text for taking the arc's false way out."""
ex = self.act2.get("exit", {})
eid = ex.get("ending", "")
pool = self.act2.get("endings", {}).get(eid, [])
return [self._loc(locale, f"act2|end|{eid}|{i}", t)
for i, t in enumerate(pool)]
def _framing(self, locale: Optional[str], kind: str) -> List[str]:
pool = self.data["framing"][kind]
return [self._loc(locale, f"framing|{kind}|{i}", t) for i, t in enumerate(pool)]
def intro(self, locale: Optional[str] = None) -> List[str]:
return self._framing(locale, "intro")
def cutscene(self, locale: Optional[str] = None) -> List[str]:
"""A short, arc-specific restatement of the rule, shown the first time
the player is reset, before the instructions. Empty if the arc has none."""
if "cutscene" not in self.data.get("framing", {}):
return []
return self._framing(locale, "cutscene")
def win(self, locale: Optional[str] = None) -> List[str]:
return self._framing(locale, "win")
def attempt_line(self, locale: Optional[str], n: int) -> str:
"""A one-line, in-world 'solved in N runs' note for the win screen.
A clean first run gets its own line; otherwise {n} is spliced in."""
if n <= 1:
return self._loc(locale, "attempt_first",
self.meta.get("attempt_first", ""))
tmpl = self._loc(locale, "attempt", self.meta.get("attempt", ""))
return tmpl.replace("{n}", str(n))
def long_look(self, locale: Optional[str], rng: random.Random) -> str:
pool = self.data["adaptive"]["long_look"]
idx = rng.randrange(len(pool))
return self._loc(locale, f"adaptive|long_look|{idx}", pool[idx])