File size: 5,188 Bytes
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
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
"""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
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.
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


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


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", {})

    def build(self, mem: PlayerMemory, rng: random.Random) -> dict:
        """Generate the next place for the player's current level.

        The probability of an anomaly stays near a coin-flip so neither
        "always continue" nor "always turn back" is a winning strategy.
        """
        has_anomaly = rng.random() < 0.5
        anomaly_prop: Optional[str] = None
        anomaly_val: Optional[str] = None
        if has_anomaly:
            anomaly_prop, anomaly_val = anomalies.pick_anomaly(self.properties, rng)

        # Decide which properties get described this loop.
        order = list(self.data["anchor_order"])
        rng.shuffle(order)
        n = rng.randint(MIN_DETAILS, MAX_DETAILS)
        shown = order[:n]
        # If there is an anomaly, it must be visible this loop -- otherwise
        # the player is asked to notice something they were never shown.
        if anomaly_prop and anomaly_prop not in shown:
            shown[rng.randrange(len(shown))] = anomaly_prop
        # 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]

        sign_text = anomalies.drift_sign(self.data["sign_variants"], rng)

        sentences: List[str] = []
        for prop in shown:
            spec = self.properties[prop]
            if prop == anomaly_prop:
                pool = spec["anomalies"][anomaly_val]
            else:
                baseline = spec["baseline"]
                pool = spec["values"][baseline]
            line = anomalies.render_sentence(rng.choice(pool), sign_text)
            sentences.append(line)

        # 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:
            tmpl = rng.choice(self.data["adaptive"]["repeat_inspect"])
            adaptive_line = tmpl.replace("{THING}", self.labels.get(fav, fav))

        heading = anomalies.drift_heading(
            self.data["title_variants"], mem.loops, rng
        )

        return {
            "heading": heading,
            "sentences": sentences,
            "adaptive": adaptive_line,
            "sign_text": sign_text,
            "shown": shown,
            # server-authoritative truth, never sent to the client:
            "_has_anomaly": has_anomaly,
            "_anomaly_prop": anomaly_prop,
            "_anomaly_val": anomaly_val,
        }

    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 intro(self) -> List[str]:
        return self.data["framing"]["intro"]

    def win(self) -> List[str]:
        return self.data["framing"]["win"]