qFelix commited on
Commit
78c0f6e
·
0 Parent(s):

Brad Did Something - Gradio/FastAPI Space on HF

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.dockerignore ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .pytest_cache/
5
+ .git/
6
+ models/
7
+ *.gguf
8
+ *.log
9
+ trace.log
10
+ .bds_modal_token
11
+ .venv/
12
+ venv/
13
+ node_modules/
14
+
15
+ # heavy, non-runtime assets — the running app never reads these
16
+ lora/
17
+ Design System/
18
+ # design/engineering docs (also gitignored) — not needed in the image
19
+ *.md
20
+ !README.md
.gitattributes ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ *.woff2 filter=lfs diff=lfs merge=lfs -text
2
+ *.png filter=lfs diff=lfs merge=lfs -text
3
+ *.jpg filter=lfs diff=lfs merge=lfs -text
4
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
5
+ *.ico filter=lfs diff=lfs merge=lfs -text
6
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # secrets — never commit or upload
2
+ .bds_modal_token
3
+
4
+ # local model weights
5
+ models/
6
+
7
+ # test artifacts + runtime logs
8
+ tests/shots/
9
+ logs/
10
+ __pycache__/
11
+ *.pyc
12
+ .pytest_cache/
13
+
14
+ # design + engineering docs — keep them local, don't ship to the Space.
15
+ # (README.md MUST stay — HF reads the Space frontmatter from it.)
16
+ /*.md
17
+ !/README.md
18
+
19
+ # non-runtime asset folders (lots of binaries) — not needed by the running app
20
+ lora/
21
+ tests/comic_samples/
22
+ Design System/
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HF Space runs this as `sdk: docker` (see README frontmatter). We can't use
2
+ # `sdk: gradio` because that runner imports a module-level `demo` and never
3
+ # starts uvicorn — our app is a FastAPI server with Gradio *mounted* on top, so
4
+ # it must be launched explicitly. Gradio stays mounted at "/", so the Space
5
+ # still "uses Gradio". Local dev is unchanged: `python app.py`.
6
+ FROM python:3.11-slim
7
+
8
+ # HF Spaces execute as a non-root user (uid 1000); give it a writable home.
9
+ RUN useradd -m -u 1000 user
10
+ WORKDIR /home/user/app
11
+
12
+ COPY --chown=user requirements.txt .
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+
15
+ COPY --chown=user . .
16
+
17
+ USER user
18
+ ENV PORT=7860
19
+ EXPOSE 7860
20
+
21
+ # MODAL_URL/MODAL_TOKEN (text) and FLUX_URL/FLUX_TOKEN (comic art) are provided
22
+ # as Space secrets; unset → mock text + no comic overlay (still fully playable).
23
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Brad Did Something
3
+ emoji: 🚀
4
+ colorFrom: pink
5
+ colorTo: yellow
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ short_description: Argue your way to $1M before the quarter ends
11
+ ---
12
+
13
+ # Brad Did Something
14
+
15
+ 2D top-down office comedy game. You are the Head of Sales and Partnerships at
16
+ Veloura Technologies. Five unhinged underlings, 15 events, one quarter, one
17
+ million dollars. The Office meets Silicon Valley, in cozy daylight pixel art.
18
+
19
+ All NPC dialogue and outcomes are generated by llama.cpp (Qwen3.5-9B)
20
+ running on Modal, with JSON-schema-enforced output validated in Python before
21
+ anything reaches the screen.
22
+
23
+ ## Run locally
24
+
25
+ ```
26
+ pip install -r requirements.txt
27
+ python app.py # → http://localhost:7860
28
+ ```
29
+
30
+ With no `MODAL_URL` set, the game runs in **mock mode** — fully playable, with
31
+ a template-based offline generator standing in for the model.
32
+
33
+ ## Wire up live inference (Modal)
34
+
35
+ Already deployed to the `qfelix0112` workspace — endpoint:
36
+ `https://qfelix0112--brad-did-something-llama-generate.modal.run`
37
+ (L4 GPU, warm calls 2-4s, stays warm 5 min between calls). The auth token
38
+ lives in `.bds_modal_token` (gitignored — never commit/upload it).
39
+
40
+ Play against it: `.\run_modal.ps1`
41
+
42
+ To redeploy after changing `modal_app/inference.py`:
43
+
44
+ ```
45
+ set PYTHONUTF8=1 # Windows: avoids a CLI encoding crash
46
+ modal deploy modal_app/inference.py # prints the web endpoint URL
47
+ python tests/smoke_modal.py # one real call per call type
48
+ ```
49
+
50
+ `MODAL_URL` is the full generate-endpoint URL (Modal gives each function its
51
+ own URL). For the HF Space, set `MODAL_URL` + `MODAL_TOKEN` as Space secrets.
52
+
53
+ ## Comic panels (FLUX image generation)
54
+
55
+ When a crisis fires, the text model also writes a wordless `image_prompt` (a
56
+ single-panel scene) plus a short `comic_caption`. A second Modal GPU app renders
57
+ the panel with **FLUX.2 [klein] 4B**; the UI shows it centered over the office
58
+ with the caption drawn as crisp text *above* the picture (the image stays
59
+ wordless because FLUX garbles in-image text). The art style is prepended
60
+ server-side in `/api/comic`, so the model spends its whole budget describing the
61
+ scene. Setup renders during the walk to the NPC, payoff after the outcome. It's
62
+ purely decorative: if FLUX is unset, slow, or fails, the game shows **no
63
+ overlay** and the dialogue opens as usual — the outcome is never blocked.
64
+
65
+ ```
66
+ set PYTHONUTF8=1
67
+ modal secret create huggingface HF_TOKEN=<hf token> # FLUX weights are gated
68
+ modal deploy modal_app/image.py # prints the endpoint URL
69
+ python tests/probe_comic.py # latency + saves a sample
70
+ ```
71
+
72
+ Then set `FLUX_URL` (the printed `generate_image` URL) and `FLUX_TOKEN` (the
73
+ same `bds-auth` `BDS_TOKEN`) — locally or as Space secrets. The model is
74
+ env-swappable at deploy: `FLUX_MODEL_ID=black-forest-labs/FLUX.1-schnell`
75
+ (Apache-2.0) is a drop-in if FLUX.2's deps/VRAM are troublesome.
76
+ **License:** the FLUX.2 line is typically non-commercial — fine for a hackathon
77
+ demo; review before any commercial use.
78
+
79
+ ## Deploy on Hugging Face (Docker)
80
+
81
+ The app is built on **`gr.Server`** (Gradio's FastAPI-based server): it serves a
82
+ fully custom canvas/DOM frontend from `static/` with zero default Gradio
83
+ widgets, while staying a first-class Gradio app. The Space uses `sdk: docker`
84
+ (see frontmatter) because HF's `sdk: gradio` runner only launches a module-level
85
+ `demo`; the `Dockerfile` runs `uvicorn app:app` on port 7860 (the `gr.Server`
86
+ instance is the ASGI app). Set `MODAL_URL`/`MODAL_TOKEN` and (optional)
87
+ `FLUX_URL`/`FLUX_TOKEN` as Space secrets. Dry-run locally:
88
+
89
+ ```
90
+ docker build -t bds .
91
+ docker run -p 7860:7860 bds # → http://localhost:7860
92
+ ```
93
+
94
+ ## Tests
95
+
96
+ ```
97
+ pytest tests/ # unit tests (validator, economy, events, comic, idle)
98
+ python tests/smoke_http.py # full-quarter API playthrough
99
+ python tests/smoke_browser.py # headless-browser UI smoke (playwright)
100
+ python tests/probe_mobile.py # touch-controls smoke on an emulated phone
101
+ python tests/probe_comic.py # live FLUX render + saves sample panels
102
+ ```
103
+
104
+ ## Controls
105
+
106
+ WASD / arrows move · SPACE talk / pick up / answer / advance comic · G gift ·
107
+ 1/2 choose options · ENTER send typed response · ESC close · M mute
108
+
109
+ **Touch / mobile:** on phones and tablets an on-screen joystick (move) plus
110
+ ACT (= SPACE) and GIFT buttons appear automatically; tapping the floor also
111
+ walks the player. Portrait layout stacks the HUD and hides the paper-trail
112
+ panel. Comics and dialogue are tap-dismissable.
113
+
114
+ ## Docs
115
+
116
+ Design docs (GAME_DESIGN, MECHANICS, EVENTS, …) and engineering docs
117
+ (ARCHITECTURE, SCHEMAS, AI_PROMPTS, IMPLEMENTATION_PLAN) live in the repo
118
+ root. Start with AGENTS.md.
app.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Brad Did Something — HF Space entry point.
2
+
3
+ Built on `gr.Server` (Gradio's FastAPI-based server, ARCHITECTURE.md D1): it
4
+ owns the /api routes and serves a fully custom canvas/DOM frontend from static/
5
+ — no default Gradio widgets. Run: python app.py (HF Docker: uvicorn app:app)
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import pathlib
11
+
12
+ import gradio as gr
13
+ import uvicorn
14
+ from fastapi import HTTPException
15
+ from fastapi.responses import FileResponse
16
+ from fastapi.staticfiles import StaticFiles
17
+ from pydantic import BaseModel, Field
18
+
19
+ from game import (comic, economy, events, idle, llm, presentation, prompts,
20
+ relationships)
21
+ from game.schemas import GIFT_TIERS, NPC_IDS, RESPONSE_TYPES
22
+ from game.state import STORE, GameState
23
+ from game.trace import trace
24
+
25
+ ROOT = pathlib.Path(__file__).parent
26
+ # gr.Server is Gradio's own FastAPI-based server (Gradio 5.x+). Using it as the
27
+ # base means the whole app is a first-class Gradio app while every route below is
28
+ # plain FastAPI — so we serve a fully custom canvas/DOM frontend (static/) with
29
+ # no default Gradio widgets. (Replaces the old FastAPI + mount_gradio_app shell.)
30
+ api = gr.Server(title="Brad Did Something")
31
+ app = api # the ASGI app served by `uvicorn app:app` (local + HF Docker)
32
+
33
+
34
+ # ------------------------------------------------------------- request models
35
+
36
+ class SessionBody(BaseModel):
37
+ session_id: str = Field(min_length=8, max_length=64)
38
+
39
+
40
+ class RespondBody(SessionBody):
41
+ response_type: str
42
+ text: str = Field(default="", max_length=400)
43
+
44
+
45
+ class GiftBody(SessionBody):
46
+ npc_id: str
47
+ tier: str
48
+
49
+
50
+ class ChatBody(SessionBody):
51
+ npc_id: str
52
+ text: str = Field(default="", max_length=400)
53
+
54
+
55
+ class ComicBody(SessionBody):
56
+ image_prompt: str = Field(default="", max_length=400)
57
+
58
+
59
+ def _get_state(session_id: str) -> GameState:
60
+ state = STORE.get(session_id)
61
+ if state is None:
62
+ raise HTTPException(404, "unknown session — start a new game")
63
+ return state
64
+
65
+
66
+ # --------------------------------------------------------------------- routes
67
+
68
+ @api.post("/api/warm")
69
+ def warm() -> dict:
70
+ """Wake the Modal containers early (called on page load) so the first
71
+ real event doesn't eat a cold start."""
72
+ llm.warm()
73
+ comic.warm()
74
+ return {"ok": True}
75
+
76
+
77
+ @api.post("/api/comic")
78
+ def comic_panel(body: ComicBody) -> dict:
79
+ """Render the crisis comic from an AI-written image_prompt. Returns
80
+ {image_b64: null} when FLUX is unavailable / fails / times out — the
81
+ client then simply skips the overlay and opens the dialogue as usual."""
82
+ _get_state(body.session_id) # session must exist
83
+ panels = body.image_prompt.strip()
84
+ if not panels:
85
+ return {"image_b64": None}
86
+ # prepend the art style here so the model spends its whole image_prompt
87
+ # budget on panel descriptions (else the ~230-char style tag ate the budget
88
+ # and the panels truncated to a single panel)
89
+ img = comic.generate_comic(f"{prompts.COMIC_STYLE}. {panels}")
90
+ return {"image_b64": img}
91
+
92
+
93
+ @api.post("/api/new_game")
94
+ def new_game() -> dict:
95
+ llm.warm() # belt-and-suspenders prewarm at game start
96
+ comic.warm()
97
+ state = STORE.create()
98
+ state.phase = "free_roam"
99
+ trace("flow", f"=== NEW GAME {state.session_id[:8]} "
100
+ f"({'LIVE ' + os.environ.get('MODAL_URL', '') if os.environ.get('MODAL_URL') else 'MOCK mode'})")
101
+ return {"session_id": state.session_id, "state": state.snapshot()}
102
+
103
+
104
+ @api.post("/api/next_event")
105
+ def next_event(body: SessionBody) -> dict:
106
+ state = _get_state(body.session_id)
107
+ if state.game_over:
108
+ raise HTTPException(409, "quarter is over")
109
+ event = events.next_event(state)
110
+ return {"event": event, "state": state.snapshot()}
111
+
112
+
113
+ @api.post("/api/respond")
114
+ def respond(body: RespondBody) -> dict:
115
+ state = _get_state(body.session_id)
116
+ if body.response_type not in RESPONSE_TYPES:
117
+ raise HTTPException(422, "bad response_type")
118
+ if state.phase != "crisis" or not state.current_event:
119
+ raise HTTPException(409, "no active crisis")
120
+ outcome = events.respond(state, body.response_type, body.text.strip())
121
+ return {"outcome": outcome, "state": state.snapshot()}
122
+
123
+
124
+ @api.post("/api/presentation_round")
125
+ def presentation_round(body: RespondBody) -> dict:
126
+ state = _get_state(body.session_id)
127
+ if state.phase != "presentation" or state.presentation is None:
128
+ raise HTTPException(409, "no active presentation")
129
+ round_data = presentation.advance(state, body.response_type, body.text.strip())
130
+ return {"round_data": round_data, "state": state.snapshot()}
131
+
132
+
133
+ @api.post("/api/gift")
134
+ def gift(body: GiftBody) -> dict:
135
+ state = _get_state(body.session_id)
136
+ if state.phase != "free_roam":
137
+ raise HTTPException(409, "gifts only between crises")
138
+ cost = economy.gift_cost(body.tier)
139
+ if cost is None:
140
+ raise HTTPException(422, "bad tier")
141
+ if state.pocket_money < cost:
142
+ raise HTTPException(409, "insufficient pocket money")
143
+ if body.tier == "coffee":
144
+ state.pocket_money -= cost
145
+ relationships.coffee_round(state)
146
+ result = {"kind": "coffee", "cost": cost}
147
+ trace("flow", f"coffee round -$50 -> morale {state.morale}")
148
+ else:
149
+ if body.npc_id not in NPC_IDS:
150
+ raise HTTPException(422, "bad npc_id")
151
+ state.pocket_money -= cost
152
+ result = relationships.give_gift(state, body.npc_id, cost)
153
+ result.update({"kind": "gift", "cost": cost, "npc_id": body.npc_id})
154
+ trace("flow", f"gift {body.tier} -> {body.npc_id} "
155
+ f"rel+{result['relationship_delta']}"
156
+ f"{' (halved)' if result['halved'] else ''}"
157
+ f"{' UNLOCKED' if result['unlocked'] else ''} "
158
+ f"-> {state.npc(body.npc_id).relationship}")
159
+ return {"result": result, "state": state.snapshot()}
160
+
161
+
162
+ @api.post("/api/chat")
163
+ def chat(body: ChatBody) -> dict:
164
+ state = _get_state(body.session_id)
165
+ if state.phase != "free_roam":
166
+ raise HTTPException(409, "chat only between crises")
167
+ if body.npc_id not in NPC_IDS:
168
+ raise HTTPException(422, "bad npc_id")
169
+ try:
170
+ if body.text.strip():
171
+ result = idle.reply_chat(state, body.npc_id, body.text.strip())
172
+ else:
173
+ result = idle.open_chat(state, body.npc_id)
174
+ except idle.IdleError as exc:
175
+ raise HTTPException(409, str(exc))
176
+ return {"chat": result, "state": state.snapshot()}
177
+
178
+
179
+ @api.post("/api/idle")
180
+ def idle_roll(body: SessionBody) -> dict:
181
+ state = _get_state(body.session_id)
182
+ if state.phase != "free_roam":
183
+ raise HTTPException(409, "idle moments only between crises")
184
+ try:
185
+ result = idle.roll_idle(state)
186
+ except idle.IdleError as exc:
187
+ raise HTTPException(409, str(exc))
188
+ return {"idle": result, "state": state.snapshot()}
189
+
190
+
191
+ @api.post("/api/read_email")
192
+ def read_email(body: SessionBody) -> dict:
193
+ state = _get_state(body.session_id)
194
+ try:
195
+ email = idle.read_email(state)
196
+ except idle.IdleError as exc:
197
+ raise HTTPException(409, str(exc))
198
+ return {"email": email, "state": state.snapshot()}
199
+
200
+
201
+ @api.post("/api/review")
202
+ def review(body: SessionBody) -> dict:
203
+ state = _get_state(body.session_id)
204
+ if state.phase != "review":
205
+ raise HTTPException(409, "quarter not finished")
206
+ data = state.review or presentation.quarterly_review(state)
207
+ return {"review": data, "state": state.snapshot()}
208
+
209
+
210
+ @api.get("/healthz")
211
+ def healthz() -> dict:
212
+ return {"ok": True}
213
+
214
+
215
+ # ------------------------------------------------------------- custom frontend
216
+
217
+ api.mount("/static", StaticFiles(directory=ROOT / "static"), name="static")
218
+
219
+
220
+ @api.get("/")
221
+ @api.get("/game")
222
+ def game_page() -> FileResponse:
223
+ """Serve the fully custom canvas/DOM game frontend (no Gradio widgets)."""
224
+ return FileResponse(ROOT / "static" / "index.html")
225
+
226
+
227
+ if __name__ == "__main__":
228
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))
game/__init__.py ADDED
File without changes
game/comic.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal FLUX client for comic-panel image generation.
2
+
3
+ If FLUX_URL is set, POSTs the image prompt to the Modal FLUX endpoint and
4
+ returns a base64 PNG. Otherwise — or on any failure/timeout — returns None,
5
+ and the frontend renders the composed chibi-comic fallback instead. Mirrors the
6
+ warm / cold-timeout pattern in llm.py.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import threading
12
+ import time
13
+
14
+ import requests
15
+
16
+ from .trace import trace
17
+
18
+ FLUX_TIMEOUT = float(os.environ.get("BDS_FLUX_TIMEOUT", "12"))
19
+ FLUX_COLD_TIMEOUT = float(os.environ.get("BDS_FLUX_COLD_TIMEOUT", "90"))
20
+ _warmed = False
21
+
22
+
23
+ def flux_available() -> bool:
24
+ return bool(os.environ.get("FLUX_URL"))
25
+
26
+
27
+ def _headers() -> dict:
28
+ return {"Authorization": "Bearer " + os.environ.get("FLUX_TOKEN", "")}
29
+
30
+
31
+ def warm() -> None:
32
+ """Boot the FLUX container in the background (page load / new game)."""
33
+ if not flux_available() or _warmed:
34
+ return
35
+ threading.Thread(target=_warm_ping, daemon=True).start()
36
+
37
+
38
+ def _warm_ping() -> None:
39
+ try:
40
+ requests.post(os.environ["FLUX_URL"], json={"warmup": True},
41
+ headers=_headers(), timeout=FLUX_COLD_TIMEOUT + 60)
42
+ trace("flux", "warmup ping done")
43
+ except requests.RequestException:
44
+ pass
45
+
46
+
47
+ def generate_comic(prompt: str) -> str | None:
48
+ """Return a base64 PNG for the comic, or None → composed fallback."""
49
+ if not flux_available() or not prompt:
50
+ return None
51
+ global _warmed
52
+ timeout = FLUX_TIMEOUT if _warmed else FLUX_COLD_TIMEOUT
53
+ t0 = time.time()
54
+ try:
55
+ resp = requests.post(os.environ["FLUX_URL"], json={"prompt": prompt},
56
+ headers=_headers(), timeout=timeout)
57
+ ms = int((time.time() - t0) * 1000)
58
+ if resp.status_code == 200:
59
+ body = resp.json()
60
+ img = body.get("image_b64")
61
+ if img:
62
+ _warmed = True
63
+ trace("flux", f"comic LIVE {ms}ms ({len(img)}b)")
64
+ return img
65
+ trace("flux", f"comic not-ok {ms}ms: "
66
+ f"{str(body.get('error', body))[:120]}")
67
+ else:
68
+ trace("flux", f"comic HTTP {resp.status_code} {ms}ms")
69
+ except requests.Timeout:
70
+ trace("flux", f"comic TIMEOUT after {timeout}s")
71
+ except requests.RequestException as exc:
72
+ trace("flux", f"comic transport error: {str(exc)[:120]}")
73
+ return None
game/context.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Global context builder — state → the exact context object in SCHEMAS.md."""
2
+ from __future__ import annotations
3
+
4
+ from .schemas import APPROVED_CLIENTS, NPC_IDS
5
+ from .state import GameState
6
+
7
+
8
+ def build_context(state: GameState) -> dict:
9
+ return {
10
+ "game_config": {
11
+ "company": "Veloura Technologies",
12
+ "player_title": "Head of Sales and Partnerships",
13
+ "crisis_number": state.crisis_number,
14
+ "total_crises": 15,
15
+ "tone": "comedic corporate satire — The Office meets Silicon Valley",
16
+ "approved_clients": APPROVED_CLIENTS,
17
+ },
18
+ "financial_state": {
19
+ "revenue": state.revenue,
20
+ "target": state.target,
21
+ "company_budget": state.company_budget,
22
+ "bonuses_issued": state.bonuses_issued,
23
+ "total_bonus_spend": state.total_bonus_spend,
24
+ "pocket_money": state.pocket_money,
25
+ "bribes_accepted": state.bribes_accepted,
26
+ },
27
+ "team_state": {
28
+ "morale": state.morale,
29
+ "npcs": {
30
+ n: {
31
+ "relationship": s.relationship,
32
+ "gifts_received": s.gifts_received,
33
+ "mood": s.mood,
34
+ "incident_count": s.incident_count,
35
+ "personal_situation": s.personal_situation,
36
+ "recent_events": s.recent_events[-3:],
37
+ }
38
+ for n, s in state.npcs.items()
39
+ },
40
+ },
41
+ "constraint_state": {
42
+ "budget_warning": state.company_budget < 5_000,
43
+ "board_scrutiny": state.board_scrutiny,
44
+ "consecutive_praise": {n: state.npc(n).consecutive_praise for n in NPC_IDS},
45
+ "consecutive_harsh": state.consecutive_harsh,
46
+ "consecutive_fine_whatever": state.consecutive_fine_whatever,
47
+ "hr_alert": state.hr_alert,
48
+ "extended_presentation": bool(
49
+ state.presentation and state.presentation.get("extended")),
50
+ },
51
+ "event_log": list(state.event_log),
52
+ }
game/economy.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Revenue, budget, bonuses, pocket money, bribery — MECHANICS.md rules."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+
6
+ from .schemas import GIFT_TIERS
7
+ from .state import GameState
8
+
9
+ BONUS_PER_EVENT_CAP = 5_000
10
+ BONUS_QUARTER_CAP = 20_000
11
+ BONUS_SCRUTINY_THRESHOLD = 3
12
+ SALARY_EVERY = 3
13
+ SALARY_AMOUNT = 1_000
14
+
15
+ # balance is driven by the crisis prompt's revenue anchors (the model follows
16
+ # them well); this multiplier on LIVE crisis deltas stays a dormant safety knob
17
+ # (1.0 = off) in case the model drifts low again. Mock deltas are not scaled.
18
+ REVENUE_SCALE = float(os.environ.get("BDS_REVENUE_SCALE", "1.0"))
19
+
20
+
21
+ def scale_revenue(raw: int) -> int:
22
+ """Scale a raw live-model revenue delta toward the intended economy."""
23
+ try:
24
+ return int(round(raw * REVENUE_SCALE))
25
+ except (TypeError, ValueError):
26
+ return 0
27
+
28
+
29
+ def apply_revenue(state: GameState, delta: int) -> int:
30
+ """Applies a revenue delta with the zero floor. Returns the applied delta."""
31
+ applied = max(delta, -state.revenue)
32
+ state.revenue += applied
33
+ return applied
34
+
35
+
36
+ def apply_pocket(state: GameState, delta: int) -> int:
37
+ applied = max(delta, -state.pocket_money)
38
+ state.pocket_money += applied
39
+ return applied
40
+
41
+
42
+ def salary_tick(state: GameState) -> bool:
43
+ """Called once per resolved crisis. Monthly salary advance every 3 crises."""
44
+ state.crises_since_salary += 1
45
+ if state.crises_since_salary >= SALARY_EVERY:
46
+ state.crises_since_salary = 0
47
+ state.pocket_money += SALARY_AMOUNT
48
+ return True
49
+ return False
50
+
51
+
52
+ def can_bonus(state: GameState, amount: int) -> bool:
53
+ return (state.company_budget >= 5_000
54
+ and amount <= BONUS_PER_EVENT_CAP
55
+ and amount <= state.company_budget
56
+ and state.total_bonus_spend + amount <= BONUS_QUARTER_CAP)
57
+
58
+
59
+ def issue_bonus(state: GameState, npc_id: str, amount: int) -> bool:
60
+ if not can_bonus(state, amount):
61
+ return False
62
+ state.company_budget -= amount
63
+ state.total_bonus_spend += amount
64
+ state.bonuses_issued += 1
65
+ state.npc(npc_id).relationship = min(100, state.npc(npc_id).relationship + 8)
66
+ if state.bonuses_issued > BONUS_SCRUTINY_THRESHOLD:
67
+ raise_scrutiny(state) # the board noticed the pattern
68
+ return True
69
+
70
+
71
+ def gift_cost(tier: str) -> int | None:
72
+ return GIFT_TIERS.get(tier)
73
+
74
+
75
+ def accept_bribe(state: GameState, offer: int) -> dict:
76
+ state.bribes_accepted += 1
77
+ state.pocket_money += offer
78
+ consequences = {"scrutiny": None, "hr": False}
79
+ if state.bribes_accepted == 2:
80
+ if state.board_scrutiny == "low":
81
+ state.board_scrutiny = "medium"
82
+ consequences["scrutiny"] = state.board_scrutiny
83
+ elif state.bribes_accepted >= 3:
84
+ state.board_scrutiny = "high" if state.board_scrutiny in (
85
+ "low", "medium") else state.board_scrutiny
86
+ state.hr_alert = True
87
+ consequences["scrutiny"] = state.board_scrutiny
88
+ consequences["hr"] = True
89
+ return consequences
90
+
91
+
92
+ def decline_bribe(state: GameState) -> None:
93
+ state.morale = min(100, state.morale + 5)
94
+
95
+
96
+ SCRUTINY_ORDER = ["low", "medium", "high", "critical"]
97
+
98
+
99
+ def raise_scrutiny(state: GameState) -> None:
100
+ i = SCRUTINY_ORDER.index(state.board_scrutiny)
101
+ if i < 2: # critical is only reached via the high-streak rule
102
+ state.board_scrutiny = SCRUTINY_ORDER[i + 1]
103
+
104
+
105
+ def lower_scrutiny(state: GameState) -> None:
106
+ i = SCRUTINY_ORDER.index(state.board_scrutiny)
107
+ if i > 0:
108
+ state.board_scrutiny = SCRUTINY_ORDER[i - 1]
109
+ state.scrutiny_high_streak = 0
110
+
111
+
112
+ def scrutiny_tick(state: GameState) -> None:
113
+ """Once per resolved crisis: high maintained >4 crises → critical."""
114
+ if state.board_scrutiny in ("high", "critical"):
115
+ state.scrutiny_high_streak += 1
116
+ if state.scrutiny_high_streak > 4:
117
+ state.board_scrutiny = "critical"
118
+ else:
119
+ state.scrutiny_high_streak = 0
120
+
121
+
122
+ def ending_tier(state: GameState) -> str:
123
+ if state.revenue >= state.target:
124
+ return "hit_target"
125
+ if state.revenue > 600_000:
126
+ return "above_600k"
127
+ if state.revenue >= 300_000:
128
+ return "300k_to_600k"
129
+ return "below_300k"
game/events.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Event sequencing: slot rolls, special events, crisis resolution."""
2
+ from __future__ import annotations
3
+
4
+ import random
5
+
6
+ from . import (economy, fallbacks, idle, llm, presentation, prompts,
7
+ relationships, validator)
8
+ from .schemas import BRIBE_AMOUNTS, NPC_IDS
9
+ from .state import PRESENTATION_EVENTS, TOTAL_CRISES, GameState
10
+ from .trace import trace
11
+
12
+ SPECIAL_BASE_CHANCE = 0.25
13
+ SPECIAL_DROUGHT_CHANCE = 0.60
14
+ DROUGHT_AFTER = 3
15
+
16
+ ARRIVALS = {
17
+ "normal": "npc",
18
+ "newspaper": "newspaper",
19
+ "bribery": "envelope",
20
+ "personal": "npc_amber",
21
+ "client_emergency": "phone",
22
+ "hr": "hr",
23
+ "suspicion": "npc_amber",
24
+ "romance": "npc_heart",
25
+ }
26
+
27
+ ROMANCE_ELIGIBLE = 70 # relationship at which an NPC may make a move
28
+
29
+
30
+ def _romance_candidate(state: GameState) -> str | None:
31
+ """Highest-relationship NPC ready to start a romance, if any."""
32
+ pool = [n for n in NPC_IDS
33
+ if state.npc(n).relationship >= ROMANCE_ELIGIBLE
34
+ and not state.npc(n).romance_active]
35
+ if not pool:
36
+ return None
37
+ return max(pool, key=lambda n: state.npc(n).relationship)
38
+
39
+
40
+ def _any_romance_active(state: GameState) -> bool:
41
+ return any(state.npc(n).romance_active for n in NPC_IDS)
42
+
43
+
44
+ def next_event(state: GameState) -> dict:
45
+ """Advance to the next event slot. Returns the event payload."""
46
+ if state.current_event is not None:
47
+ return state.current_event # idempotent: crisis already active
48
+ if state.crisis_number >= TOTAL_CRISES:
49
+ state.phase = "review"
50
+ return {"kind": "review"}
51
+
52
+ state.crisis_number += 1
53
+ idle.reset_gap(state) # idle budgets refresh with every new event slot
54
+
55
+ if state.crisis_number in PRESENTATION_EVENTS:
56
+ state.phase = "presentation"
57
+ presentation.start(state)
58
+ trace("flow", f"event {state.crisis_number}: PRESENTATION "
59
+ f"(presenting={state.presentation['presenting_npc']} "
60
+ f"state={state.presentation['npc_state']} "
61
+ f"rounds={state.presentation['total_rounds']})")
62
+ event = {
63
+ "kind": "presentation",
64
+ "arrival": "boardroom",
65
+ "crisis_number": state.crisis_number,
66
+ "final": state.crisis_number == TOTAL_CRISES,
67
+ }
68
+ state.current_event = event
69
+ return event
70
+
71
+ kind = _roll_slot(state)
72
+ if kind == "normal":
73
+ event = _normal_pitch(state)
74
+ elif kind == "bribery":
75
+ event = _bribery_event(state)
76
+ else:
77
+ event = _special_event(state, kind)
78
+
79
+ event["kind"] = "crisis"
80
+ event["special"] = kind if kind != "normal" else None
81
+ event["arrival"] = ARRIVALS[kind]
82
+ event["crisis_number"] = state.crisis_number
83
+ state.current_event = event
84
+ state.phase = "crisis"
85
+ trace("flow", f"event {state.crisis_number}: {kind} npc={event['affected_npc']} "
86
+ f"\"{event['headline']}\" (drought={state.special_drought})")
87
+ return event
88
+
89
+
90
+ def _roll_slot(state: GameState) -> str:
91
+ if state.queued_special:
92
+ kind = state.queued_special
93
+ state.queued_special = None
94
+ state.special_drought = 0
95
+ return kind
96
+ chance = (SPECIAL_DROUGHT_CHANCE if state.special_drought >= DROUGHT_AFTER
97
+ else SPECIAL_BASE_CHANCE)
98
+ if random.random() < chance:
99
+ state.special_drought = 0
100
+ if state.hr_alert:
101
+ return "hr"
102
+ pool = ["newspaper", "bribery", "personal", "client_emergency"]
103
+ # one romance at a time; offer it when someone is ready to make a move
104
+ if _romance_candidate(state) and not _any_romance_active(state):
105
+ pool += ["romance", "romance"] # weight it up once eligible
106
+ return random.choice(pool)
107
+ state.special_drought += 1
108
+ return "normal"
109
+
110
+
111
+ def _pick_npc(state: GameState) -> str:
112
+ """Prefer NPCs with fewer incidents; mild bias toward interesting moods."""
113
+ weights = []
114
+ for n in NPC_IDS:
115
+ npc = state.npc(n)
116
+ w = 4.0 / (1 + npc.incident_count)
117
+ if npc.mood not in ("normal", "happy"):
118
+ w *= 1.4
119
+ weights.append(w)
120
+ return random.choices(NPC_IDS, weights=weights)[0]
121
+
122
+
123
+ def _normal_pitch(state: GameState) -> dict:
124
+ npc_id = _pick_npc(state)
125
+ system, user = prompts.event_prompt(state, f"normal pitch from {npc_id}")
126
+ payload = llm.call_validated(state, "event", system, user,
127
+ lambda p: validator.validate_event(p, state),
128
+ requested_type="normal", npc_id=npc_id)
129
+ if payload is None:
130
+ state.fallback_count += 1
131
+ payload = fallbacks.event_fallback(state.crisis_number)
132
+ payload["affected_npc"] = npc_id
133
+ payload["affected_npc"] = payload.get("affected_npc") or npc_id
134
+ if payload["affected_npc"] == "player":
135
+ payload["affected_npc"] = npc_id
136
+ return payload
137
+
138
+
139
+ def _special_event(state: GameState, kind: str) -> dict:
140
+ romance_npc = _romance_candidate(state) if kind == "romance" else None
141
+ flavors = {
142
+ "newspaper": "newspaper incident — a quarter disaster reached the press",
143
+ "personal": "personal NPC life event bleeding into work",
144
+ "client_emergency": "client emergency — a client situation just detonated",
145
+ "hr": "formal HR conversation the player must navigate"
146
+ + (" — it is about an office romance" if _any_romance_active(state)
147
+ else ""),
148
+ "suspicion": "the team has collectively noticed the player praising too much; "
149
+ "they suspect layoffs or worse",
150
+ "romance": (
151
+ f"an office-romance moment: {romance_npc} has clearly developed "
152
+ "feelings for the player (their boss) and finally makes a move — a "
153
+ "lingering after-hours conversation, a too-personal gift, a "
154
+ "confession disguised as a work question. option_a is the player "
155
+ "LEANING IN / reciprocating; option_b is the player keeping it "
156
+ "warmly professional. Both are first-person player actions. Stay "
157
+ "sweet and absurd, never explicit."),
158
+ }
159
+ system, user = prompts.event_prompt(state, flavors[kind])
160
+ payload = llm.call_validated(state, "event", system, user,
161
+ lambda p: validator.validate_event(p, state),
162
+ requested_type=kind, romance_npc=romance_npc)
163
+ if payload is None:
164
+ state.fallback_count += 1
165
+ # type-matched fallback so a newspaper stays a press story, etc.
166
+ payload = (fallbacks.romance_fallback(romance_npc) if kind == "romance"
167
+ else fallbacks.special_fallback(kind, state.crisis_number))
168
+ if kind == "newspaper":
169
+ payload["affected_npc"] = "brad" # usually Brad. It is always Brad.
170
+ state.npc("brad").mood = "sheepish"
171
+ if kind == "romance" and romance_npc:
172
+ payload["affected_npc"] = romance_npc # the resolution needs the target
173
+ return payload
174
+
175
+
176
+ def _bribery_event(state: GameState) -> dict:
177
+ offer = random.choice(BRIBE_AMOUNTS)
178
+ state.pending_bribe = offer
179
+ return {
180
+ "affected_npc": "player",
181
+ "category": "financial",
182
+ "headline": "An envelope has appeared on your desk",
183
+ "intro": f"> no sender. inside: ${offer:,} and a note: 'for your "
184
+ "flexibility on the Northpath Solutions terms. there is more "
185
+ "where this came from.'",
186
+ "option_a": f"ACCEPT — pocket the ${offer:,}. No immediate consequence. Immediate is doing a lot of work there.",
187
+ "option_b": "DECLINE — slide it back under the door of reality.",
188
+ "urgency": "The envelope is slightly warm. Why is it warm.",
189
+ "setup_animation": "bribery_envelope",
190
+ "morale_preview": 0,
191
+ "bribe_offer": offer,
192
+ }
193
+
194
+
195
+ # ---------------------------------------------------------------- responding
196
+
197
+ def respond(state: GameState, response_type: str, text: str) -> dict:
198
+ """Resolve the active crisis with the player's response."""
199
+ event = state.current_event
200
+ if not event or event.get("kind") != "crisis":
201
+ raise ValueError("no active crisis")
202
+
203
+ trace("flow", f"respond [{response_type}]"
204
+ + (f" \"{text}\"" if text else ""))
205
+ if event.get("special") == "bribery":
206
+ outcome = _resolve_bribery(state, response_type, text)
207
+ else:
208
+ outcome = _resolve_crisis(state, event, response_type, text)
209
+
210
+ # bookkeeping shared by every resolution
211
+ salary_paid = economy.salary_tick(state)
212
+ economy.scrutiny_tick(state)
213
+ state.current_event = None
214
+ state.phase = "free_roam"
215
+ outcome["salary_paid"] = salary_paid
216
+ outcome["state_phase"] = state.phase
217
+ return outcome
218
+
219
+
220
+ def _resolve_crisis(state: GameState, event: dict, response_type: str,
221
+ text: str) -> dict:
222
+ npc_id = event["affected_npc"]
223
+ if npc_id == "player":
224
+ npc_id = random.choice(NPC_IDS)
225
+ system, user = prompts.crisis_prompt(state, npc_id, event, response_type, text)
226
+ payload = llm.call_validated(
227
+ state, "crisis", system, user,
228
+ lambda p: validator.validate_crisis(p, state, npc_id),
229
+ npc_id=npc_id, response_type=response_type,
230
+ player_response=text, crisis=event)
231
+ if payload is None:
232
+ state.fallback_count += 1
233
+ trace("flow", f"FALLBACK outcome for {npc_id} "
234
+ f"(#{state.fallback_count} this session)")
235
+ payload = fallbacks.crisis_fallback(npc_id, response_type)
236
+ payload = validator.validate_crisis(payload, state, npc_id)
237
+
238
+ # a loss that hit the $0 floor still deserves its moment
239
+ if payload.get("floored_loss"):
240
+ payload["consequence"] = (payload["consequence"].rstrip(". ")[:130]
241
+ + ". There was nothing left to lose.")
242
+
243
+ # apply deltas server-side
244
+ morale_before = state.morale
245
+ applied_rev = economy.apply_revenue(state, payload["revenue_delta"])
246
+ relationships.apply_morale(state, payload["morale_delta"])
247
+ npc = state.npc(npc_id)
248
+ before_rel = npc.relationship
249
+ relationships.apply_relationship(state, npc_id, payload["relationship_delta"])
250
+ unlocked = relationships.crossed_unlock(before_rel, npc.relationship)
251
+ economy.apply_pocket(state, payload["pocket_money_delta"])
252
+ npc.incident_count += 1
253
+ npc.recent_events.append(payload["log_entry"])
254
+ relationships.update_mood_from_outcome(state, npc_id, payload["animation"])
255
+ trace("flow", f"applied: rev {applied_rev:+,} -> ${state.revenue:,} | "
256
+ f"morale {morale_before}->{state.morale} | "
257
+ f"rel[{npc_id}] {before_rel}->{npc.relationship} | "
258
+ f"mood={npc.mood} anim={payload['animation']}"
259
+ + (" | RELATIONSHIP UNLOCKED" if unlocked else ""))
260
+
261
+ # guardrails driven by the player's words
262
+ tone = relationships.classify_tone(response_type, text)
263
+ praise_result = relationships.praise_tick(state, npc_id, tone)
264
+ if praise_result:
265
+ trace("econ", f"praise guardrail: {praise_result} for {npc_id}")
266
+ if praise_result == "suspicion_event":
267
+ state.queued_special = "suspicion"
268
+ relationships.harsh_tick(state, tone)
269
+ if tone == "harsh":
270
+ trace("econ", f"harsh tone detected -> rel[{npc_id}] -6, morale -3")
271
+ relationships.apply_relationship(state, npc_id, -6)
272
+
273
+ if response_type == "quick_fine":
274
+ state.consecutive_fine_whatever += 1
275
+ npc.mood = "smug" # their confidence increases. This is worse.
276
+ trace("econ", f"FINE WHATEVER #{state.consecutive_fine_whatever} "
277
+ "since last presentation")
278
+ else:
279
+ state.consecutive_fine_whatever = 0
280
+
281
+ if event.get("special") == "newspaper":
282
+ state.newspaper_count += 1
283
+ if applied_rev < 0:
284
+ economy.raise_scrutiny(state)
285
+ trace("econ", f"newspaper handled badly -> scrutiny {state.board_scrutiny}")
286
+
287
+ # romance resolution: option_a (or an affectionate typed reply) = leaning in
288
+ if event.get("special") == "romance":
289
+ pursued = response_type == "option_a" or (
290
+ response_type == "custom"
291
+ and validator.ROMANCE_WORDS.search(text or ""))
292
+ if pursued:
293
+ npc.romance_active = True
294
+ relationships.apply_relationship(state, npc_id, 6)
295
+ npc.mood = "heart_eyes"
296
+ trace("flow", f"romance: {npc_id} is now dating the player "
297
+ f"(rel {npc.relationship})")
298
+ else:
299
+ relationships.apply_relationship(state, npc_id, -4)
300
+ trace("flow", f"romance: player kept it professional with {npc_id}")
301
+
302
+ # romance has real stakes: crossing 80 while dating draws HR's eye
303
+ if (npc.romance_active and npc.relationship > 80 and not state.hr_alert):
304
+ state.hr_alert = True
305
+ state.queued_special = "hr"
306
+ trace("econ", f"romance with {npc_id} crossed 80 -> HR alert queued")
307
+
308
+ if payload["special_next_event"]:
309
+ state.queued_special = payload["special_next_event"]
310
+ trace("flow", f"AI queued special: {payload['special_next_event']}")
311
+
312
+ entry = f"Event {state.crisis_number} — {payload['log_entry']}"
313
+ state.log(entry)
314
+ state.trail(npc_id, payload["log_entry"], applied_rev)
315
+ state.boss_title = payload["boss_title"]
316
+
317
+ return {
318
+ "npc_id": npc_id,
319
+ "npc_reaction": payload["npc_reaction"],
320
+ "consequence": payload["consequence"],
321
+ "revenue_delta": applied_rev,
322
+ "animation": payload["animation"],
323
+ "boss_title": payload["boss_title"],
324
+ "relationship_unlocked": unlocked,
325
+ "image_prompt": payload.get("image_prompt"), # comic payoff (optional)
326
+ "comic_caption": payload.get("comic_caption"), # caption text over it
327
+ }
328
+
329
+
330
+ def _resolve_bribery(state: GameState, response_type: str, text: str) -> dict:
331
+ offer = state.pending_bribe
332
+ state.pending_bribe = 0
333
+ trace("flow", f"bribery resolution [{response_type}] offer=${offer:,}")
334
+ name_line = ""
335
+ if response_type in ("option_b", "quick_no"): # decline
336
+ response_type = "option_b"
337
+ elif response_type in ("quick_fine",): # capitulating to a bribe = taking it
338
+ response_type = "option_a"
339
+ elif response_type in ("quick_explain", "quick_quit"):
340
+ response_type = "option_b" # deflecting still means not taking the money
341
+ if response_type == "option_a": # accept
342
+ consequences = economy.accept_bribe(state, offer)
343
+ trace("econ", f"bribe ACCEPTED #{state.bribes_accepted} -> "
344
+ f"scrutiny={state.board_scrutiny} hr={state.hr_alert}")
345
+ reaction = (f"> ${offer:,} transferred to personal account. the note "
346
+ "dissolves. somewhere, a spreadsheet updates.")
347
+ consequence = "No immediate consequence. The word immediate is doing a lot of work."
348
+ if consequences["hr"]:
349
+ consequence = "HR has been CC'd on something. The something is you."
350
+ anim = "hr_stamp" if consequences["hr"] else "bribery_envelope"
351
+ log = f"An envelope appeared. The player accepted ${offer:,}. Officially, nothing happened."
352
+ delta = 0
353
+ elif response_type == "option_b": # decline
354
+ economy.decline_bribe(state)
355
+ reaction = "> envelope returned. integrity intact. the team somehow respects this through a mechanism nobody can explain."
356
+ consequence = "Morale improved. Nobody knows how they knew. They knew."
357
+ anim = "npc_grateful"
358
+ log = "An envelope appeared. The player declined it. The team respected it, mysteriously."
359
+ delta = 0
360
+ else: # counter-offer or any custom response
361
+ gained = min(offer, max(0, offer // 2))
362
+ state.pocket_money += gained
363
+ state.bribes_accepted += 1
364
+ reaction = (f"> counter received. they laughed, then paid ${gained:,}. "
365
+ "respect, of a kind, has been established.")
366
+ consequence = "You negotiated with a bribe. The bribe respects you now."
367
+ anim = "bribery_envelope"
368
+ log = f"An envelope appeared. The player negotiated. ${gained:,} changed pockets."
369
+ delta = 0
370
+ entry = f"Event {state.crisis_number} — {log}"
371
+ state.log(entry)
372
+ state.trail("player", log, delta)
373
+ return {
374
+ "npc_id": None,
375
+ "npc_reaction": reaction + name_line,
376
+ "consequence": consequence,
377
+ "revenue_delta": 0,
378
+ "animation": anim,
379
+ "boss_title": state.boss_title,
380
+ "relationship_unlocked": False,
381
+ }
game/fallbacks.py ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pre-written fallbacks — content from AI_PROMPTS.md. The player must never
2
+ see an error: any Modal timeout or validation failure lands here."""
3
+ from __future__ import annotations
4
+
5
+ import random
6
+
7
+ REACTIONS = {
8
+ "brad": {
9
+ "positive": "Knew you'd see it, boss. This is why we're a great team. I already told two people.",
10
+ "neutral": "Okay. Noted. Circling back. The window's still open by the way. Brad-window.",
11
+ "negative": "Wow. Okay. That's a choice. I'm putting this in my book. There's a chapter now.",
12
+ },
13
+ "stacey": {
14
+ "positive": "Oh thank god. Thank you. I already drafted three apology emails, I'll only send one.",
15
+ "neutral": "Right, yes, totally — I'll fix it. I know exactly how. Mostly exactly.",
16
+ "negative": "No that's fair. That's completely fair. I'm so sorry. I'll just— yes. Okay.",
17
+ },
18
+ "kevin": {
19
+ "positive": "Directionally, this validates everything. I'll add a slide. The slide will be green.",
20
+ "neutral": "Interesting. The data didn't predict this. I'll adjust the methodology. Quietly.",
21
+ "negative": "With respect, the numbers disagree. I'll re-run them until they don't.",
22
+ },
23
+ "janet": {
24
+ "positive": "THIS is leadership with a point of view. I'm putting it in the newsletter. With a metaphor.",
25
+ "neutral": "Fine. But the brand will remember how this felt.",
26
+ "negative": "I hear you. The vision doesn't, but I do.",
27
+ },
28
+ "derek": {
29
+ "positive": "Hm. That's what Margaret would have done. Before the incident.",
30
+ "neutral": "Noted. We tried that in 2019. Well. Something like it.",
31
+ "negative": "...Understood.",
32
+ },
33
+ }
34
+
35
+ _KIND_DELTAS = {
36
+ "positive": (25_000, 40_000, 2, 2, "npc_happy"),
37
+ "neutral": (-5_000, 10_000, 0, 0, "npc_confused"),
38
+ "negative": (-40_000, -15_000, -3, -3, "npc_devastated"),
39
+ }
40
+
41
+ NPC_NAMES = {"brad": "Brad", "stacey": "Stacey", "kevin": "Kevin",
42
+ "janet": "Janet", "derek": "Derek"}
43
+
44
+
45
+ def kind_for_response(response_type: str) -> str:
46
+ if response_type in ("quick_fine",):
47
+ return "negative"
48
+ if response_type in ("custom", "quick_explain", "quick_quit"):
49
+ return "neutral"
50
+ return "positive"
51
+
52
+
53
+ def crisis_fallback(npc_id: str, response_type: str) -> dict:
54
+ kind = kind_for_response(response_type)
55
+ lo, hi, morale, rel, anim = _KIND_DELTAS[kind]
56
+ delta = random.randint(lo // 1000, hi // 1000) * 1000
57
+ name = NPC_NAMES[npc_id]
58
+ sign = "+" if delta >= 0 else "-"
59
+ return {
60
+ "npc_reaction": REACTIONS[npc_id][kind],
61
+ "consequence": f"{name} handled it. Nobody is entirely sure how, and nobody asked.",
62
+ "revenue_delta": delta,
63
+ "animation": anim,
64
+ "boss_title": "Acting Head of Whatever This Is",
65
+ "log_entry": f"{name} had a situation. It was handled. {sign}${abs(delta) // 1000}K.",
66
+ "morale_delta": morale,
67
+ "npc_id": npc_id,
68
+ "relationship_delta": rel,
69
+ "pocket_money_delta": 0,
70
+ "special_next_event": None,
71
+ }
72
+
73
+
74
+ _EVENT_FALLBACKS = [
75
+ {
76
+ "affected_npc": "player",
77
+ "category": "professional",
78
+ "headline": "The printer has produced something",
79
+ "intro": "> inbox: the printer in the kitchen has been printing the same page for twenty minutes. People have seen it. It is a ranking.",
80
+ "option_a": "Shred everything and declare a paperless office, effective immediately.",
81
+ "option_b": "Pin it to the corkboard and call it radical transparency.",
82
+ "urgency": "It is still printing.",
83
+ "setup_animation": "npc_confused",
84
+ "morale_preview": -3,
85
+ },
86
+ {
87
+ "affected_npc": "stacey",
88
+ "category": "professional",
89
+ "headline": "Wrong attachment, right energy",
90
+ "intro": "So the good news is the client got the file on time. The other news is it was the internal nicknames spreadsheet. Their CEO is 'Captain Synergy'. He has replied.",
91
+ "option_a": "Claim it was an icebreaker initiative and send the rest of the spreadsheet.",
92
+ "option_b": "Blame a software glitch nobody can name.",
93
+ "urgency": "He has replied TWICE.",
94
+ "setup_animation": "npc_crying",
95
+ "morale_preview": -4,
96
+ },
97
+ {
98
+ "affected_npc": "brad",
99
+ "category": "external",
100
+ "headline": "Mystery package addressed to nobody",
101
+ "intro": "Boss. Package at reception. No sender. I opened it. That part's done, so. It's five hundred stress balls with a competitor's logo. I have a theory.",
102
+ "option_a": "Distribute the stress balls. Free is free.",
103
+ "option_b": "Mail them back with a strongly worded sticky note.",
104
+ "urgency": "Brad's theory has slides.",
105
+ "setup_animation": "npc_smug",
106
+ "morale_preview": 2,
107
+ },
108
+ {
109
+ "affected_npc": "kevin",
110
+ "category": "personal",
111
+ "headline": "It is Kevin's birthday",
112
+ "intro": "For the record I did not expect anyone to remember. The data suggested a 12 percent chance. I brought my own hat. Directionally, this is fine.",
113
+ "option_a": "Emergency cake run on company budget. Backdate the enthusiasm.",
114
+ "option_b": "Declare birthdays a Q4 initiative.",
115
+ "urgency": "He is wearing the hat.",
116
+ "setup_animation": "npc_devastated",
117
+ "morale_preview": -5,
118
+ },
119
+ ]
120
+
121
+
122
+ def event_fallback(index: int) -> dict:
123
+ return dict(_EVENT_FALLBACKS[index % len(_EVENT_FALLBACKS)])
124
+
125
+
126
+ # type-matched fallbacks so a special's arrival animation never contradicts its
127
+ # content (a "newspaper" arrival must not fall back to a birthday, etc.)
128
+ _SPECIAL_FALLBACKS = {
129
+ "newspaper": {
130
+ "affected_npc": "brad",
131
+ "category": "external",
132
+ "headline": "The press got hold of it",
133
+ "intro": "Boss. So. A reporter wrote us up. The headline uses the word "
134
+ "'reportedly' four times and there's a photo of me mid-sentence. "
135
+ "It's already being shared. I look powerful though.",
136
+ "option_a": "Issue a correction so dry nobody finishes reading it.",
137
+ "option_b": "Lean in and frame the whole thing as bold market disruption.",
138
+ "urgency": "The comment section has discovered us.",
139
+ "setup_animation": "npc_hiding",
140
+ "morale_preview": -5,
141
+ },
142
+ "client_emergency": {
143
+ "affected_npc": "stacey",
144
+ "category": "professional",
145
+ "headline": "A client is on the line, right now",
146
+ "intro": "I have Northpath Solutions on hold and they are not happy. "
147
+ "Something about a deliverable we may have described as 'basically "
148
+ "done'. It was not basically done. It was basically a folder.",
149
+ "option_a": "Take the call yourself and promise a recovery plan by Friday.",
150
+ "option_b": "Have Stacey stall with enthusiasm while we invent the thing.",
151
+ "urgency": "They can hear the hold music looping. So can we.",
152
+ "setup_animation": "npc_crying",
153
+ "morale_preview": -6,
154
+ },
155
+ "personal": {
156
+ "affected_npc": "kevin",
157
+ "category": "personal",
158
+ "headline": "Something is going on with the team",
159
+ "intro": "Not a work thing, technically. But it's bleeding into the work "
160
+ "thing. There were tears at the printer. The printer is fine. The "
161
+ "person is, statistically, also fine. Probably.",
162
+ "option_a": "Check in personally and quietly cover their afternoon.",
163
+ "option_b": "Declare a surprise team lunch and never address it directly.",
164
+ "urgency": "The whole floor is pretending to type.",
165
+ "setup_animation": "npc_devastated",
166
+ "morale_preview": -4,
167
+ },
168
+ "hr": {
169
+ "affected_npc": "player",
170
+ "category": "professional",
171
+ "headline": "HR would like a quick word",
172
+ "intro": "> HR has requested a brief, informal, absolutely-not-a-big-deal "
173
+ "conversation regarding 'recent patterns'. They have used the "
174
+ "phrase 'just to document it'. There is a folder.",
175
+ "option_a": "Walk in honest and own whatever this is about.",
176
+ "option_b": "Bring your own folder. Establish folder dominance.",
177
+ "urgency": "The meeting room blinds are already closed.",
178
+ "setup_animation": "npc_suspicious",
179
+ "morale_preview": -5,
180
+ },
181
+ }
182
+
183
+
184
+ def special_fallback(kind: str, index: int) -> dict:
185
+ """A coherent fallback whose theme matches the special-event arrival."""
186
+ if kind in _SPECIAL_FALLBACKS:
187
+ return dict(_SPECIAL_FALLBACKS[kind])
188
+ return event_fallback(index)
189
+
190
+
191
+ def presentation_fallback(round_no: int, last_log: str) -> dict:
192
+ if round_no >= 3:
193
+ return {
194
+ "round": round_no,
195
+ "board_tone": "neutral",
196
+ "event_referenced": last_log[:150],
197
+ "round_difficulty": "standard",
198
+ "board_dialogue": "The board has reviewed the quarter so far. Specifically this: "
199
+ f"\"{last_log[:120]}\". Give us your closing statement.",
200
+ "cumulative_score": 50,
201
+ }
202
+ return {
203
+ "round": round_no,
204
+ "board_tone": "neutral",
205
+ "event_referenced": last_log[:150],
206
+ "round_difficulty": "standard",
207
+ "option_a": "Own it completely and pivot to the pipeline.",
208
+ "option_b": "Contextualize it as a learning investment.",
209
+ "board_dialogue": f"Let's start with this item from the record: \"{last_log[:120]}\". "
210
+ "Walk us through it.",
211
+ }
212
+
213
+
214
+ CHAT_OPENERS = {
215
+ "brad": "Boss. Glad you stopped by. I'm working on something big. Can't say what. It's big though.",
216
+ "stacey": "Oh! Hi. Everything's under control. I just triple-checked the recipient field on everything. Twice.",
217
+ "kevin": "Good timing. The numbers are doing something interesting. Directionally interesting.",
218
+ "janet": "I've been thinking about our visual language. We need to talk about it. Not now. But soon.",
219
+ "derek": "Hm. You walk the floor now. Interesting.",
220
+ }
221
+
222
+ CHAT_REPLIES = {
223
+ "brad": "Knew you'd get it, boss. This is why I tell people we're tight.",
224
+ "stacey": "That actually helps. Thank you. I'll only worry about it a normal amount now.",
225
+ "kevin": "Noted. I'll factor that into the model. The model appreciates it.",
226
+ "janet": "See, THIS is the kind of dialogue the brand needs internally.",
227
+ "derek": "Hm. Noted.",
228
+ }
229
+
230
+
231
+ def chat_fallback(npc_id: str, opener: bool) -> dict:
232
+ return {
233
+ "npc_line": (CHAT_OPENERS if opener else CHAT_REPLIES)[npc_id],
234
+ "relationship_delta": 0 if opener else 1,
235
+ "morale_delta": 0,
236
+ }
237
+
238
+
239
+ BANTER_LINES = [
240
+ ("brad", "...so I told them, that's not a bug, that's a premium feature. They went quiet. Closing energy."),
241
+ ("kevin", "The Q3 numbers are directionally fine. Directionally."),
242
+ ("janet", "The font says reliable. We are not a reliable font company."),
243
+ ("stacey", "Okay but who do I apologize to if nobody noticed yet?"),
244
+ ("derek", "We had a printer like that in 2019. Before the incident."),
245
+ ]
246
+
247
+
248
+ def banter_fallback(index: int) -> dict:
249
+ npc_id, line = BANTER_LINES[index % len(BANTER_LINES)]
250
+ return {"npc_id": npc_id, "line": line}
251
+
252
+
253
+ EAVESDROP_EXCHANGES = [
254
+ [("brad", "Kevin. Buddy. Your chart says we grew 140 percent."),
255
+ ("kevin", "The chart is directionally accurate, Brad."),
256
+ ("brad", "I put it in the client deck.")],
257
+ [("janet", "The newsletter needs a hero image that says resilience."),
258
+ ("stacey", "Is that why you sent me forty photos of lighthouses?"),
259
+ ("janet", "Forty OPTIONS, Stacey.")],
260
+ ]
261
+
262
+
263
+ def eavesdrop_fallback(index: int) -> dict:
264
+ exchange = EAVESDROP_EXCHANGES[index % len(EAVESDROP_EXCHANGES)]
265
+ return {"lines": [{"speaker": s, "line": l} for s, l in exchange]}
266
+
267
+
268
+ EMAIL_BANK = [
269
+ {"sender": "janet", "subject": "BRAND PULSE — week of now",
270
+ "body": "Team. This week the brand felt like a lighthouse in fog: present, vertical, misunderstood. More on this in my longer email. There will be a longer email."},
271
+ {"sender": "kevin", "subject": "Metric of the Day",
272
+ "body": "Pipeline velocity is up 31% against a baseline I have defined myself. Methodology available upon request. Please do not request it."},
273
+ {"sender": "system", "subject": "FACILITIES: regarding the printer",
274
+ "body": "The third-floor printer has been restored to factory settings. We are not able to explain what it was printing before. Please direct questions nowhere."},
275
+ ]
276
+
277
+
278
+ def email_fallback(index: int) -> dict:
279
+ return dict(EMAIL_BANK[index % len(EMAIL_BANK)])
280
+
281
+
282
+ _ROMANCE_INTROS = {
283
+ "brad": "Boss. Off the record. I've been thinking — we're a great team, right? Like, a GREAT team. I made you a playlist. It's all walk-on music.",
284
+ "stacey": "Okay so this is unprofessional and I've rehearsed it four times and deleted three drafts, but — would it be weird if I said I look forward to our one-on-ones? More than the agenda warrants?",
285
+ "kevin": "I ran the numbers on our working relationship and the trend line is, directionally, very warm. I made a slide. The slide has a heart on it. The x-axis is us.",
286
+ "janet": "I've been building a mood board. It's about us. It's mostly the color of a sunset and one photo of your stapler. I think it's saying something. I think it's saying a lot.",
287
+ "derek": "Hm. You stayed late again. So did I. ...That's all. Unless it isn't. It might not be. Hm.",
288
+ }
289
+
290
+
291
+ def romance_fallback(npc_id: str) -> dict:
292
+ npc_id = npc_id or "stacey"
293
+ return {
294
+ "affected_npc": npc_id,
295
+ "category": "personal",
296
+ "headline": f"{NPC_NAMES[npc_id]} has feelings, apparently",
297
+ "intro": _ROMANCE_INTROS[npc_id],
298
+ "option_a": "Lean in. Say you've felt it too. What's the worst that happens.",
299
+ "option_b": "Smile, keep it warm, and steer firmly back to the quarterly numbers.",
300
+ "urgency": "The whole floor is pretending not to watch.",
301
+ "setup_animation": "heart_float",
302
+ "morale_preview": 4,
303
+ }
304
+
305
+
306
+ def verdict_fallback(tier: str) -> dict:
307
+ verdicts = {
308
+ "hit_target": "Against every available signal, the number is real. The board has voted to stop asking how.",
309
+ "above_600k": "Close. The board has expressed this in an emoji, in Slack, three times. You know the one.",
310
+ "300k_to_600k": "The board wants a call. Not a good call. You already know the energy of the call.",
311
+ "below_300k": "The board has drafted something. It mentions your continued presence. It is currently unsigned.",
312
+ }
313
+ return {"verdict": verdicts[tier]}
game/idle.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Idle activities between crises: small talk, banter, eavesdrop, emails.
2
+
3
+ All AI-generated from live game state; budgets are enforced per roam gap so
4
+ idle chat can never become a relationship farm (plan: ±2 rel / ±1 morale per
5
+ gap, 1 chat per NPC, 2 chats per gap, 2 turns per chat, 1 ambient roll).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import random
10
+
11
+ from . import fallbacks, llm, prompts, relationships, validator
12
+ from .schemas import NPC_IDS
13
+ from .state import GameState
14
+ from .trace import trace
15
+
16
+ MAX_CHATS_PER_GAP = 2
17
+ MAX_TURNS_PER_CHAT = 2
18
+
19
+
20
+ def reset_gap(state: GameState) -> None:
21
+ """Called by events.next_event when a new event slot begins."""
22
+ state.chats_this_gap = {}
23
+ state.idle_done_this_gap = False
24
+ state.chat_session = None
25
+
26
+
27
+ class IdleError(Exception):
28
+ """Cap or sequencing violation — maps to HTTP 409."""
29
+
30
+
31
+ # ------------------------------------------------------------------ chat
32
+
33
+ def open_chat(state: GameState, npc_id: str) -> dict:
34
+ if state.chats_this_gap.get(npc_id, 0) >= 1:
35
+ raise IdleError(f"{npc_id} has already been chatted with this gap")
36
+ if sum(state.chats_this_gap.values()) >= MAX_CHATS_PER_GAP:
37
+ raise IdleError("chat budget for this gap is spent")
38
+ state.chats_this_gap[npc_id] = 1
39
+ state.chat_session = {"npc_id": npc_id, "turns": 1}
40
+
41
+ system, user = prompts.chat_prompt(state, npc_id, None)
42
+ payload, _live = llm.call_model(state, "chat", system, user, npc_id=npc_id)
43
+ payload = validator.validate_chat(payload, state, npc_id) if payload else None
44
+ if payload is None:
45
+ state.fallback_count += 1
46
+ payload = fallbacks.chat_fallback(npc_id, opener=True)
47
+ trace("flow", f"chat open [{npc_id}] \"{payload['npc_line'][:60]}\"")
48
+ # openers never move stats — only the player's reply can
49
+ return {"npc_id": npc_id, "npc_line": payload["npc_line"],
50
+ "can_reply": True}
51
+
52
+
53
+ def reply_chat(state: GameState, npc_id: str, text: str) -> dict:
54
+ session = state.chat_session
55
+ if not session or session["npc_id"] != npc_id:
56
+ raise IdleError("no open chat with this npc")
57
+ if session["turns"] >= MAX_TURNS_PER_CHAT:
58
+ raise IdleError("this chat is over — they have work to pretend to do")
59
+ session["turns"] += 1
60
+
61
+ system, user = prompts.chat_prompt(state, npc_id, text)
62
+ payload, _live = llm.call_model(state, "chat", system, user,
63
+ npc_id=npc_id, player_text=text)
64
+ payload = validator.validate_chat(payload, state, npc_id) if payload else None
65
+ if payload is None:
66
+ state.fallback_count += 1
67
+ payload = fallbacks.chat_fallback(npc_id, opener=False)
68
+
69
+ # micro-effects, guardrails included
70
+ tone = relationships.classify_tone("custom", text)
71
+ rel_delta = payload["relationship_delta"]
72
+ if tone == "harsh":
73
+ rel_delta = min(rel_delta, -1)
74
+ applied = relationships.apply_relationship(state, npc_id, rel_delta)
75
+ relationships.apply_morale(state, payload["morale_delta"])
76
+ praise_result = relationships.praise_tick(state, npc_id, tone)
77
+ if praise_result == "suspicion_event":
78
+ state.queued_special = "suspicion"
79
+ if praise_result:
80
+ trace("econ", f"praise guardrail via chat: {praise_result} for {npc_id}")
81
+ trace("flow", f"chat reply [{npc_id}] rel{applied:+d} "
82
+ f"-> {state.npc(npc_id).relationship} "
83
+ f"\"{payload['npc_line'][:60]}\"")
84
+ state.chat_session = None
85
+ return {"npc_id": npc_id, "npc_line": payload["npc_line"],
86
+ "relationship_delta": applied, "can_reply": False}
87
+
88
+
89
+ # ------------------------------------------------------------- ambient roll
90
+
91
+ def _eligible_pairs(state: GameState) -> list[tuple[str, str]]:
92
+ talked = [n for n in NPC_IDS if state.npc(n).incident_count > 0
93
+ or state.npc(n).mood != "normal"]
94
+ pool = talked if len(talked) >= 2 else NPC_IDS
95
+ pairs = []
96
+ for i, a in enumerate(pool):
97
+ for b in pool[i + 1:]:
98
+ pairs.append((a, b))
99
+ return pairs
100
+
101
+
102
+ def roll_idle(state: GameState) -> dict:
103
+ if state.idle_done_this_gap:
104
+ raise IdleError("ambient moment already happened this gap")
105
+ state.idle_done_this_gap = True
106
+
107
+ roll = random.random()
108
+ if state.pending_email is None and roll < 0.25:
109
+ kind = "email"
110
+ elif roll < 0.60:
111
+ kind = "banter"
112
+ else:
113
+ kind = "eavesdrop"
114
+
115
+ if kind == "email":
116
+ system, user = prompts.email_prompt(state)
117
+ payload, _live = llm.call_model(state, "email", system, user)
118
+ payload = validator.validate_email(payload, state) if payload else None
119
+ if payload is None:
120
+ state.fallback_count += 1
121
+ payload = fallbacks.email_fallback(state.crisis_number)
122
+ state.pending_email = payload
123
+ trace("flow", f"idle: email from {payload['sender']} "
124
+ f"\"{payload['subject']}\"")
125
+ return {"kind": "email_waiting"}
126
+
127
+ if kind == "eavesdrop":
128
+ pair = random.choice(_eligible_pairs(state))
129
+ system, user = prompts.eavesdrop_prompt(state, *pair)
130
+ payload, _live = llm.call_model(state, "eavesdrop", system, user,
131
+ pair=pair)
132
+ payload = (validator.validate_eavesdrop(payload, state, pair)
133
+ if payload else None)
134
+ if payload is None:
135
+ state.fallback_count += 1
136
+ payload = fallbacks.eavesdrop_fallback(state.crisis_number)
137
+ for i, entry in enumerate(payload["lines"]):
138
+ entry["speaker"] = pair[i % 2]
139
+ trace("flow", f"idle: eavesdrop {pair[0]}+{pair[1]} "
140
+ f"({len(payload['lines'])} lines)")
141
+ return {"kind": "eavesdrop", "lines": payload["lines"]}
142
+
143
+ system, user = prompts.banter_prompt(state)
144
+ payload, _live = llm.call_model(state, "banter", system, user)
145
+ payload = validator.validate_banter(payload, state) if payload else None
146
+ if payload is None:
147
+ state.fallback_count += 1
148
+ payload = fallbacks.banter_fallback(state.crisis_number)
149
+ trace("flow", f"idle: banter [{payload['npc_id']}] "
150
+ f"\"{payload['line'][:60]}\"")
151
+ return {"kind": "banter", "npc_id": payload["npc_id"],
152
+ "line": payload["line"]}
153
+
154
+
155
+ def read_email(state: GameState) -> dict:
156
+ if state.pending_email is None:
157
+ raise IdleError("no email waiting")
158
+ email = state.pending_email
159
+ state.pending_email = None
160
+ trace("flow", f"email read: \"{email['subject']}\"")
161
+ return email
game/llm.py ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal client + offline mock model.
2
+
3
+ If MODAL_URL is set, calls the Modal llama.cpp endpoint (8s timeout, no retry).
4
+ Otherwise — or on any failure — a deterministic-enough mock generates
5
+ schema-valid, in-character output so the game is fully playable offline.
6
+ The caller still runs everything through validator.py either way.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import random
12
+ import threading
13
+ import time
14
+
15
+ import requests
16
+
17
+ from . import fallbacks
18
+ from .context import build_context
19
+ from .schemas import SCHEMA_BY_CALL_TYPE
20
+ from .state import GameState
21
+ from .trace import trace, trace_payload
22
+
23
+ # 8s in production (TECHNICAL.md); raise via env for slow local CPU inference
24
+ TIMEOUT_S = float(os.environ.get("BDS_LLM_TIMEOUT", "8"))
25
+ # the FIRST call to a cold Modal container waits longer (boot + model load)
26
+ COLD_TIMEOUT = float(os.environ.get("BDS_COLD_TIMEOUT", "55"))
27
+ _warmed = False # flips true after the first successful live response
28
+
29
+
30
+ def modal_available() -> bool:
31
+ return bool(os.environ.get("MODAL_URL"))
32
+
33
+
34
+ def warm() -> None:
35
+ """Fire a throwaway request so Modal boots the container in the background
36
+ (called on page load / new game, long before the first real event)."""
37
+ if not modal_available() or _warmed:
38
+ return
39
+ threading.Thread(target=_warm_ping, daemon=True).start()
40
+
41
+
42
+ def _warm_ping() -> None:
43
+ try:
44
+ requests.post(
45
+ os.environ["MODAL_URL"],
46
+ json={"call_type": "verdict", "system_prompt": "warmup",
47
+ "user_prompt": "warmup", "context": {},
48
+ "schema": SCHEMA_BY_CALL_TYPE["verdict"]},
49
+ headers={"Authorization": "Bearer " + os.environ.get("MODAL_TOKEN", "")},
50
+ timeout=COLD_TIMEOUT + 60)
51
+ trace("llm", "warmup ping done")
52
+ except requests.RequestException:
53
+ pass
54
+
55
+
56
+ def call_model(state: GameState, call_type: str, system_prompt: str,
57
+ user_prompt: str, **mock_kwargs) -> tuple[dict | None, bool]:
58
+ """Returns (payload, used_live_model). payload None means caller must
59
+ use fallbacks.py."""
60
+ if modal_available():
61
+ global _warmed
62
+ timeout = TIMEOUT_S if _warmed else COLD_TIMEOUT # first call waits for boot
63
+ t0 = time.time()
64
+ try:
65
+ # MODAL_URL is the full endpoint URL (Modal gives each function
66
+ # its own URL; the local dev server uses .../generate)
67
+ resp = requests.post(
68
+ os.environ["MODAL_URL"],
69
+ json={
70
+ "call_type": call_type,
71
+ "system_prompt": system_prompt,
72
+ "user_prompt": user_prompt,
73
+ "context": build_context(state),
74
+ "schema": SCHEMA_BY_CALL_TYPE[call_type],
75
+ },
76
+ headers={"Authorization": "Bearer " + os.environ.get("MODAL_TOKEN", "")},
77
+ timeout=timeout,
78
+ )
79
+ ms = int((time.time() - t0) * 1000)
80
+ if resp.status_code == 200:
81
+ body = resp.json()
82
+ if body.get("ok") and isinstance(body.get("data"), dict):
83
+ _warmed = True # container is hot now → short timeouts
84
+ data = body["data"]
85
+ # the live model lowballs revenue — scale crisis deltas into
86
+ # the intended economy (mock deltas are already balanced)
87
+ if call_type == "crisis" and "revenue_delta" in data:
88
+ from . import economy
89
+ data["revenue_delta"] = economy.scale_revenue(
90
+ data["revenue_delta"])
91
+ trace("llm", f"{call_type} LIVE {ms}ms")
92
+ trace_payload(data)
93
+ return data, True
94
+ trace("llm", f"{call_type} endpoint not-ok {ms}ms: "
95
+ f"{str(body.get('error', body))[:160]}")
96
+ else:
97
+ trace("llm", f"{call_type} HTTP {resp.status_code} {ms}ms")
98
+ except requests.Timeout:
99
+ trace("llm", f"{call_type} TIMEOUT after {timeout}s")
100
+ except requests.RequestException as exc:
101
+ trace("llm", f"{call_type} transport error: {str(exc)[:120]}")
102
+ return None, False # live mode: failure → real fallback path
103
+ payload = _mock(state, call_type, **mock_kwargs)
104
+ trace("llm", f"{call_type} MOCK")
105
+ return payload, False
106
+
107
+
108
+ def call_validated(state: GameState, call_type: str, system_prompt: str,
109
+ user_prompt: str, validate, **mock_kwargs) -> dict | None:
110
+ """Call the model and validate. If the model produced a payload that fails
111
+ validation, retry ONCE with a corrective nudge built from the reject reason
112
+ — a single model slip (a repeated round, third-person intro) shouldn't force
113
+ a fallback. Transport failures (timeouts) skip the retry (warm/cold timeout
114
+ + pre-warm handle those). Returns a validated payload, or None for fallback.
115
+ """
116
+ payload, _live = call_model(state, call_type, system_prompt, user_prompt,
117
+ **mock_kwargs)
118
+ if payload is None:
119
+ return None # transport failure → straight to fallback
120
+ ok = validate(payload)
121
+ if ok is not None:
122
+ return ok
123
+ from . import validator # lazy: avoid import cycle
124
+ reason = getattr(validator, "LAST_REJECT", None) or "it broke the required format"
125
+ nudge = (user_prompt + "\n\nIMPORTANT: your previous reply was rejected "
126
+ f"because {reason}. Write a brand-new, corrected version that fixes "
127
+ "exactly that. Do not repeat your previous reply.")
128
+ trace("llm", f"{call_type} retry after reject: {reason}")
129
+ payload2, _live2 = call_model(state, call_type, system_prompt, nudge,
130
+ **mock_kwargs)
131
+ if payload2 is None:
132
+ return None
133
+ return validate(payload2)
134
+
135
+
136
+ # ---------------------------------------------------------------- mock model
137
+
138
+ _BOSS_TITLES = [
139
+ "VP of Explaining Brad", "Director of Damage Adjacent",
140
+ "Chief Apology Architect", "Head of Saying It's Fine",
141
+ "Interim Adult In The Room", "Senior Crisis Sommelier",
142
+ "Director of Plausible Deniability", "Head of Vibes, Acting",
143
+ "Chief Brad Containment Officer", "VP of Unscheduled Honesty",
144
+ ]
145
+
146
+ _CRISIS_TEMPLATES = {
147
+ "brad": [
148
+ ("Brad promised {client} a feature that does not exist yet",
149
+ "Boss. Quick one. I may have told {client} we ship the AI thing Friday. We don't have an AI thing. But picture this: we DO.",
150
+ "Tell them Friday means a Friday, conceptually, in the future.",
151
+ "Have engineering 'demo' a screen recording of a competitor's product.",
152
+ "They booked a launch party. Catered."),
153
+ ("Brad went on a podcast nobody approved",
154
+ "So the episode dropped. I said some numbers. The numbers were aspirational. The host called me a disruptor, so, win?",
155
+ "Issue a correction calling the numbers 'directional'.",
156
+ "Book Brad on a second podcast to walk back the first podcast.",
157
+ "{client} has listened. Twice."),
158
+ ],
159
+ "stacey": [
160
+ ("Stacey sent the internal pricing sheet to {client}",
161
+ "I am so sorry. The email went to the wrong {client} contact. It had our REAL prices in it. The ones with the column called 'what we'd actually take'.",
162
+ "Claim the document was a decoy from a security drill.",
163
+ "Honor the real prices and call it a loyalty discount.",
164
+ "They replied with a thumbs up. Just the thumbs up."),
165
+ ("Stacey's follow-up arrived before the first email",
166
+ "Okay so. The follow-up to the apology went out before the apology. So they got 'again, so sorry about that' about nothing. Then the thing happened.",
167
+ "Send the original email now and pretend time is a circle.",
168
+ "Apologize for the apology, creating an apology loop.",
169
+ "She has a flowchart of the situation. It loops."),
170
+ ],
171
+ "kevin": [
172
+ ("Kevin's chart reached the client and it adds to 140 percent",
173
+ "Before you say anything: the pie chart is directionally correct. {client} asked why it sums to 140. I find the question small-minded, but it's escalating.",
174
+ "Tell them the extra 40 percent is forward-looking momentum.",
175
+ "Resend the chart with the axis removed entirely.",
176
+ "Their analyst has tweeted the chart."),
177
+ ("Kevin cited a statistic on a client call that does not exist",
178
+ "I said 9 out of 10 CTOs prefer us. Methodology: I asked Brad nine times and myself once. The client wants the source. I am the source.",
179
+ "Commission a real survey, results due never.",
180
+ "Define 'CTO' broadly enough that it's true.",
181
+ "They put it in THEIR deck."),
182
+ ],
183
+ "janet": [
184
+ ("Janet rebranded the product overnight",
185
+ "I had a breakthrough at 2am. New name, new colors, new feel. The old brand was a cry for help. I've already updated the website. And the invoices.",
186
+ "Roll it back and tell Janet the market 'wasn't ready'.",
187
+ "Keep the rebrand and update four hundred client contracts.",
188
+ "{client} just asked who 'Veloura² ' is."),
189
+ ("Janet's Substack mentioned a client by feel",
190
+ "I never NAMED {client}. I described an energy. The energy was unmistakably theirs and now their CMO follows me. This is reach. This might be good?",
191
+ "Have Janet write a flattering follow-up about a fictional company.",
192
+ "Take the post down, igniting Janet's vision discourse.",
193
+ "The post is 'resonating'."),
194
+ ],
195
+ "derek": [
196
+ ("Derek approved something nobody knew he could approve",
197
+ "There was a form. I have always signed that form. Since the incident, someone has to. The vendor starts Monday. You will want to know what vendor. Hm.",
198
+ "Unwind the approval and find out what the form was.",
199
+ "Let it ride. Derek has never been wrong. Probably.",
200
+ "The vendor sent a fruit basket. It's addressed to Derek."),
201
+ ("Derek has been marking meetings as 'attended in spirit'",
202
+ "Calendar software is new. 2009 new. I attend the meetings that matter. The others I attend in spirit. {client} noticed I was a spirit at theirs.",
203
+ "Institute mandatory camera-on, radicalizing Derek.",
204
+ "Tell the client Derek is a strategic silent presence.",
205
+ "He was in the building. Nobody knows where."),
206
+ ],
207
+ }
208
+
209
+ _REACTION_BY_KIND = {
210
+ "great": {
211
+ "brad": "BOSS. That's a closer move. I'm screenshotting this for the book. Chapter one: us.",
212
+ "stacey": "Oh that's— that's actually perfect. I can fix everything with that. Sending now. Thank you.",
213
+ "kevin": "Huh. The data did not predict that, but I'll backfill the model. Narrative momentum: green.",
214
+ "janet": "Okay. OKAY. That's a brand moment. I felt that. The client will feel that.",
215
+ "derek": "Hm. Bold. Margaret tried that once. It worked, that time.",
216
+ },
217
+ "good": {
218
+ "brad": "Solid call boss. Not the Brad play, but solid. I'll spin it. Spinning is closing.",
219
+ "stacey": "Yes — okay, yes, I can work with that. Drafting it now. Carefully. Triple-checking the recipient.",
220
+ "kevin": "Acceptable. I'll annotate the deck accordingly. The footnote will be small.",
221
+ "janet": "Fine. It's not the vision but it's... adjacent to the vision. I'll make it feel intentional.",
222
+ "derek": "Noted.",
223
+ },
224
+ "bad": {
225
+ "brad": "Oof. Okay. The client's gonna feel that one. I'll soften it with energy. So much energy.",
226
+ "stacey": "Oh no. Okay. I mean — you're the boss. I'll send it. I'll start apologizing in advance.",
227
+ "kevin": "I want it logged that the data disagreed. The data and I are aligned on this.",
228
+ "janet": "This is how brands die, but sure. I'll execute it. Minimally.",
229
+ "derek": "...As you wish. We did this in 2019. Hm.",
230
+ },
231
+ "capitulate": {
232
+ "brad": "YES. Boss said go. Boss said GO. I'm already calling them. This is the Brad timeline now.",
233
+ "stacey": "Oh. Really? Okay! I mean, if you're sure. I'll do exactly the thing. Exactly as described.",
234
+ "kevin": "Excellent. Proceeding precisely as proposed. The model says this ends well for approximately me.",
235
+ "janet": "Approved?! The vision is ALIVE. I'm updating everything. Everything is so updated.",
236
+ "derek": "Very well. For the record, I proposed it knowing you would say no. Hm.",
237
+ },
238
+ }
239
+
240
+ _CONSEQUENCES = {
241
+ "great": ["{client} signed an expanded scope by end of day.",
242
+ "The client laughed, then signed. Mostly in that order.",
243
+ "It worked. Nobody is more surprised than the team."],
244
+ "good": ["The situation stabilized. Stabilized is the new thriving.",
245
+ "{client} accepted the explanation with minor side-eye.",
246
+ "Contained. A small invoice for 'goodwill flowers' will appear."],
247
+ "bad": ["{client} asked for a 'recalibration call'. It's 90 minutes.",
248
+ "It leaked internally. The kitchen knows. The kitchen talks.",
249
+ "The fix created a smaller, more personal problem."],
250
+ "capitulate": ["It went exactly as proposed and exactly as badly as expected.",
251
+ "{client} is 'pausing the relationship to reflect'.",
252
+ "Legal-adjacent emails were exchanged. Nobody won."],
253
+ }
254
+
255
+
256
+ def _creativity_score(text: str) -> int:
257
+ """0-2: crude proxy for specificity/effort of a custom response."""
258
+ t = (text or "").strip()
259
+ if len(t) < 15:
260
+ return 0
261
+ score = 1
262
+ if len(t) > 60 and any(c.isupper() for c in t) and (
263
+ "," in t or "." in t[:-1] or "—" in t):
264
+ score = 2
265
+ return score
266
+
267
+
268
+ def _mock(state: GameState, call_type: str, **kw) -> dict:
269
+ rng = random.Random()
270
+ client = rng.choice(build_context(state)["game_config"]["approved_clients"])
271
+
272
+ if call_type == "crisis":
273
+ npc_id = kw["npc_id"]
274
+ rt = kw["response_type"]
275
+ text = kw.get("player_response", "")
276
+ if rt == "quick_fine":
277
+ kind, lo, hi = "capitulate", -150_000, -60_000
278
+ elif rt in ("option_a", "option_b"):
279
+ kind, lo, hi = rng.choice([("good", -25_000, 55_000),
280
+ ("bad", -70_000, 15_000)])
281
+ elif rt == "custom":
282
+ c = _creativity_score(text)
283
+ kind, lo, hi = [("bad", -60_000, 5_000), ("good", -15_000, 60_000),
284
+ ("great", 25_000, 140_000)][c]
285
+ elif rt == "quick_quit":
286
+ kind, lo, hi = "good", -15_000, 25_000
287
+ else: # quick_no / quick_explain
288
+ kind, lo, hi = "good", -20_000, 40_000
289
+ delta = rng.randint(lo // 1000, hi // 1000) * 1000
290
+ morale = {"great": rng.randint(4, 9), "good": rng.randint(0, 4),
291
+ "bad": rng.randint(-8, -2), "capitulate": rng.randint(-15, -8)}[kind]
292
+ rel = {"great": rng.randint(6, 12), "good": rng.randint(2, 6),
293
+ "bad": rng.randint(-12, -5), "capitulate": rng.randint(3, 8)}[kind]
294
+ anim = {"great": "npc_celebrating", "good": "npc_happy",
295
+ "bad": rng.choice(["npc_devastated", "npc_angry", "npc_confused"]),
296
+ "capitulate": "npc_smug"}[kind]
297
+ if delta <= -80_000:
298
+ anim = "disaster_flash"
299
+ elif delta >= 150_000:
300
+ anim = "revenue_rain"
301
+ name = fallbacks.NPC_NAMES[npc_id]
302
+ sign = "+" if delta >= 0 else "-"
303
+ return {
304
+ "npc_reaction": _REACTION_BY_KIND[kind][npc_id],
305
+ "consequence": rng.choice(_CONSEQUENCES[kind]).format(client=client),
306
+ "revenue_delta": delta,
307
+ "animation": anim,
308
+ "boss_title": rng.choice(_BOSS_TITLES),
309
+ "log_entry": f"{name}: {kw['crisis'].get('headline', 'a situation')[:80]}. "
310
+ f"{sign}${abs(delta) // 1000}K.",
311
+ "morale_delta": morale,
312
+ "npc_id": npc_id,
313
+ "relationship_delta": rel,
314
+ "pocket_money_delta": 0,
315
+ "special_next_event": None,
316
+ }
317
+
318
+ if call_type == "event":
319
+ requested = kw.get("requested_type")
320
+ if requested == "normal" and kw.get("npc_id"):
321
+ npc_id = kw["npc_id"]
322
+ used = " ".join(state.event_log)
323
+ pool = [t for t in _CRISIS_TEMPLATES[npc_id]
324
+ if t[0].split(" ", 1)[1][:25] not in used]
325
+ head, intro, a, b, urgency = rng.choice(pool or _CRISIS_TEMPLATES[npc_id])
326
+ return {
327
+ "affected_npc": npc_id,
328
+ "category": "professional",
329
+ "headline": head.format(client=client)[:80],
330
+ "intro": intro.format(client=client)[:200],
331
+ "option_a": a.format(client=client)[:150],
332
+ "option_b": b.format(client=client)[:150],
333
+ "urgency": urgency.format(client=client)[:80],
334
+ "setup_animation": "npc_confused",
335
+ "morale_preview": rng.randint(-5, 2),
336
+ }
337
+ if kw.get("requested_type") == "romance":
338
+ return fallbacks.romance_fallback(kw.get("romance_npc"))
339
+ # special event: rotate through the safe fallback bank + bribery synth
340
+ ev = fallbacks.event_fallback(len(state.event_log) + rng.randint(0, 3))
341
+ return ev
342
+
343
+ if call_type in ("presentation_round", "presentation_closing"):
344
+ round_no = kw["round_no"]
345
+ last = state.event_log[-1] if state.event_log else "an uneventful quarter, allegedly"
346
+ pick = rng.choice(state.event_log[-6:]) if state.event_log else last
347
+ tone = {"low": "warm", "medium": "neutral",
348
+ "high": "concerned", "critical": "alarmed"}[state.board_scrutiny]
349
+ if state.morale < 20:
350
+ tone = "alarmed"
351
+ if call_type == "presentation_round":
352
+ questions = [
353
+ f"We'd like to begin with this item from the record: \"{pick[:110]}\". Walk us through the thinking, if thinking occurred.",
354
+ f"The record contains the phrase \"{pick[:100]}\". The board has read it several times. Explain.",
355
+ ]
356
+ return {
357
+ "round": round_no,
358
+ "board_tone": tone,
359
+ "event_referenced": pick[:150],
360
+ "round_difficulty": {"warm": "easy", "neutral": "standard",
361
+ "concerned": "hard", "alarmed": "brutal"}[tone],
362
+ "option_a": "Own it fully and redirect to the revenue trend.",
363
+ "option_b": "Reframe it as deliberate culture-building.",
364
+ "board_dialogue": questions[(round_no - 1) % 2],
365
+ }
366
+ # closing: score from transcript quality + revenue position
367
+ transcript = kw.get("transcript", [])
368
+ base = 42 + sum(_creativity_score(t.get("player_response", "")) * 6
369
+ for t in transcript if t.get("player_response"))
370
+ rev_factor = max(-15, min(15, int(25 * (state.revenue / state.target
371
+ - 0.45))))
372
+ score = max(0, min(100, base + rev_factor + rng.randint(-8, 8)))
373
+ if round_no == 4:
374
+ body = ("One last thing. Off the record, which is a thing boards say "
375
+ "before remembering everything. What would your team say it "
376
+ "is like to work for you this quarter?")
377
+ else:
378
+ body = (f"The board has heard enough context. Including \"{pick[:90]}\". "
379
+ "Give us your closing statement.")
380
+ return {
381
+ "round": round_no,
382
+ "board_tone": tone,
383
+ "event_referenced": pick[:150],
384
+ "round_difficulty": "standard",
385
+ "board_dialogue": body,
386
+ "cumulative_score": score,
387
+ }
388
+
389
+ if call_type == "chat":
390
+ npc_id = kw["npc_id"]
391
+ if kw.get("player_text"):
392
+ line = fallbacks.CHAT_REPLIES[npc_id]
393
+ rel = rng.choice([0, 1, 1])
394
+ else:
395
+ mood_lines = {
396
+ "smug": "Things are going extremely my way today, boss. Ask me how.",
397
+ "sad": "I'm fine. The week is just... a lot of week.",
398
+ "devastated": "I don't want to talk about the thing. Okay, one question about the thing.",
399
+ "suspicious": "You're checking in a lot lately. Should I be updating my resume, or...?",
400
+ "grateful": "Hey — thanks again. For the thing. You know the thing.",
401
+ }
402
+ line = mood_lines.get(state.npc(npc_id).mood,
403
+ fallbacks.CHAT_OPENERS[npc_id])
404
+ rel = 0
405
+ return {"npc_line": line, "relationship_delta": rel, "morale_delta": 0}
406
+
407
+ if call_type == "banter":
408
+ return fallbacks.banter_fallback(rng.randint(0, 9))
409
+
410
+ if call_type == "eavesdrop":
411
+ pair = kw.get("pair", ("brad", "kevin"))
412
+ ex = fallbacks.eavesdrop_fallback(rng.randint(0, 9))
413
+ # remap speakers onto the requested pair so validation passes
414
+ for i, entry in enumerate(ex["lines"]):
415
+ entry["speaker"] = pair[i % 2]
416
+ return ex
417
+
418
+ if call_type == "email":
419
+ return fallbacks.email_fallback(rng.randint(0, 9))
420
+
421
+ if call_type == "verdict":
422
+ tier = kw["tier"]
423
+ pick = (random.choice(state.event_log) if state.event_log
424
+ else "the quarter")
425
+ verdicts = {
426
+ "hit_target": f"One million dollars, despite the entry reading \"{pick[:90]}\". The board has voted to stop asking how.",
427
+ "above_600k": f"Close. The board re-read \"{pick[:90]}\" and sent the same emoji in Slack, three times. You know the one.",
428
+ "300k_to_600k": f"The board wants a call. The agenda is one line and the line is \"{pick[:80]}\".",
429
+ "below_300k": f"The board has drafted something regarding your continued presence. Exhibit A reads: \"{pick[:80]}\".",
430
+ }
431
+ return {"verdict": verdicts[tier][:300]}
432
+
433
+ raise ValueError(f"unknown call type {call_type}")
game/presentation.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stakeholder presentations — PRESENTATION_SYSTEM.md."""
2
+ from __future__ import annotations
3
+
4
+ import random
5
+
6
+ from . import economy, fallbacks, llm, prompts, validator
7
+ from .state import TOTAL_CRISES, GameState
8
+ from .trace import trace
9
+
10
+
11
+ def start(state: GameState) -> None:
12
+ extended = state.scrutiny_high_streak >= 3 or state.morale < 20
13
+ presenting_npc, npc_state = _presence(state)
14
+ # pre-assign DISTINCT logged events as per-round topics. Left to its own
15
+ # devices the model re-asks the same question every round; pinning each
16
+ # option round to a different event (and telling it what's already covered)
17
+ # is what actually stops the repeats.
18
+ topics = list(state.event_log)
19
+ random.shuffle(topics)
20
+ state.presentation = {
21
+ "round": 0,
22
+ "total_rounds": 4 if extended else 3,
23
+ "extended": extended,
24
+ "transcript": [],
25
+ "presenting_npc": presenting_npc,
26
+ "npc_state": npc_state,
27
+ "wrong_slide_pending": npc_state == "romance",
28
+ "final": state.crisis_number == TOTAL_CRISES,
29
+ "score": None,
30
+ "topics": topics,
31
+ }
32
+
33
+
34
+ def _presence(state: GameState) -> tuple[str, str]:
35
+ """Pick the presenting NPC and their state per PRESENTATION_SYSTEM.md."""
36
+ for npc_id, npc in state.npcs.items():
37
+ if npc.romance_active:
38
+ return npc_id, "romance"
39
+ for npc_id, npc in state.npcs.items():
40
+ if npc.personal_situation:
41
+ return npc_id, "grief"
42
+ for npc_id, npc in state.npcs.items():
43
+ if npc.consecutive_praise >= 2:
44
+ return npc_id, "overprepared"
45
+ for npc_id, npc in state.npcs.items():
46
+ if npc.relationship < 30:
47
+ return npc_id, "bare_minimum"
48
+ for npc_id, npc in state.npcs.items():
49
+ if npc.relationship > 65 and npc.gifts_received > 0:
50
+ return npc_id, "advocate"
51
+ return "kevin", "normal" # Kevin always has slides. Kevin IS slides.
52
+
53
+
54
+ _PRESENCE_NOTES = {
55
+ "romance": "The presenting NPC is romantically involved with the player. "
56
+ "Their deck contains a wrong slide: a photo of the player with "
57
+ "hand-drawn hearts. The board has seen it. Round 1 is about it.",
58
+ "grief": "The presenting NPC is going through something personal. Grey "
59
+ "slides, melancholy titles, trailing off mid-sentence. The board "
60
+ "may ask if the team is okay before asking about numbers.",
61
+ "overprepared": "The presenting NPC has received excessive praise and "
62
+ "produced far more slides than requested. They will not "
63
+ "be redirected easily.",
64
+ "bare_minimum": "The presenting NPC was treated harshly this quarter. "
65
+ "Three slides where eight were expected. One-sentence "
66
+ "answers. The board notices the energy.",
67
+ "advocate": "The presenting NPC has a strong relationship with the player. "
68
+ "Their section is unusually strong and advocates for the "
69
+ "player's leadership unprompted.",
70
+ "normal": "The presenting NPC prepared the slides. The slides are wrong "
71
+ "in the normal way: confidently.",
72
+ }
73
+
74
+
75
+ def advance(state: GameState, response_type: str, text: str) -> dict:
76
+ """Record the player's answer (if any) and produce the next round —
77
+ or the final outcome after the last round."""
78
+ p = state.presentation
79
+ if p is None:
80
+ raise ValueError("no active presentation")
81
+
82
+ if p["round"] > 0:
83
+ if not text and not response_type:
84
+ # repeated "start" call (client retry/double-fire): re-serve the
85
+ # current round instead of advancing on an empty answer
86
+ if p.get("last_round"):
87
+ trace("flow", f"presentation round {p['round']} re-served "
88
+ "(duplicate start call)")
89
+ return p["last_round"]
90
+ trace("flow", f"presentation answer r{p['round']} [{response_type}]"
91
+ + (f" \"{text}\"" if text else ""))
92
+ p["transcript"][-1]["player_response"] = text or response_type
93
+
94
+ if p["round"] >= p["total_rounds"]:
95
+ return _finish(state)
96
+
97
+ p["round"] += 1
98
+ round_no = p["round"]
99
+ closing = round_no >= 3
100
+ call_type = "presentation_closing" if closing else "presentation_round"
101
+
102
+ # round 1 & 2 each get a distinct assigned topic; closing rounds synthesize
103
+ topics = p.get("topics") or []
104
+ covered = topics[:round_no - 1]
105
+ topic = topics[round_no - 1] if (not closing and round_no - 1 < len(topics)) \
106
+ else None
107
+ system, user = prompts.presentation_prompt(
108
+ state, round_no, p["total_rounds"], p["transcript"],
109
+ _PRESENCE_NOTES[p["npc_state"]], topic=topic, covered=covered)
110
+ prev_dialogues = tuple(t["board_dialogue"] for t in p["transcript"])
111
+ payload = llm.call_validated(
112
+ state, call_type, system, user,
113
+ lambda pl: validator.validate_presentation(pl, state, round_no,
114
+ closing, prev_dialogues),
115
+ round_no=round_no, transcript=p["transcript"])
116
+ if payload is None:
117
+ state.fallback_count += 1
118
+ trace("flow", f"FALLBACK presentation round {round_no} "
119
+ f"(#{state.fallback_count} this session)")
120
+ last = state.event_log[-1] if state.event_log else "the quarter so far"
121
+ payload = fallbacks.presentation_fallback(round_no, last)
122
+ trace("flow", f"board r{round_no}/{p['total_rounds']} tone={payload['board_tone']} "
123
+ f"diff={payload['round_difficulty']} "
124
+ f"ref=\"{str(payload['event_referenced'])[:60]}\"")
125
+
126
+ p["transcript"].append({
127
+ "round": round_no,
128
+ "board_dialogue": payload["board_dialogue"],
129
+ "player_response": None,
130
+ })
131
+ if closing and "cumulative_score" in payload:
132
+ p["score"] = payload["cumulative_score"]
133
+
134
+ wrong_slide = p["wrong_slide_pending"] and round_no == 1
135
+ if wrong_slide:
136
+ p["wrong_slide_pending"] = False
137
+
138
+ p["last_round"] = {
139
+ "kind": "round",
140
+ "round": round_no,
141
+ "total_rounds": p["total_rounds"],
142
+ "board_tone": payload["board_tone"],
143
+ "board_dialogue": payload["board_dialogue"],
144
+ "option_a": payload.get("option_a"),
145
+ "option_b": payload.get("option_b"),
146
+ "input_only": closing,
147
+ "presenting_npc": p["presenting_npc"],
148
+ "npc_state": p["npc_state"],
149
+ "wrong_slide": wrong_slide,
150
+ "is_last_round": round_no >= p["total_rounds"],
151
+ }
152
+ return p["last_round"]
153
+
154
+
155
+ def _finish(state: GameState) -> dict:
156
+ p = state.presentation
157
+ score = p["score"] if p["score"] is not None else 50
158
+
159
+ # sanity floor: the model scores conservatively (~50) even for strong
160
+ # answers, so substantive answers earn a rising baseline — this is what
161
+ # lets good presentations actually swing positive instead of netting zero.
162
+ answers = [t.get("player_response") or "" for t in p["transcript"]]
163
+ substantive = sum(1 for a in answers if len(a) >= 30)
164
+ floor = min(72, 40 + 11 * substantive)
165
+ if score < floor:
166
+ trace("vald", f"score floor: model said {score}, {substantive} "
167
+ f"substantive answers -> floor {floor}")
168
+ score = floor
169
+
170
+ # round 4 self-awareness adjustment
171
+ if p["total_rounds"] == 4 and p["transcript"]:
172
+ final_answer = p["transcript"][-1].get("player_response") or ""
173
+ if len(final_answer) > 60:
174
+ state.morale = min(100, state.morale + 5)
175
+ else:
176
+ state.morale = max(0, state.morale - 5)
177
+
178
+ swing = int((score - 50) / 50 * 200_000)
179
+ applied = economy.apply_revenue(state, swing)
180
+ budget_unlock = 0
181
+ if score >= 70:
182
+ budget_unlock = 10_000
183
+ state.company_budget += budget_unlock
184
+ if score >= 75:
185
+ economy.lower_scrutiny(state)
186
+ elif score <= 35:
187
+ economy.raise_scrutiny(state)
188
+ if score >= 60:
189
+ state.morale = min(100, state.morale + 4)
190
+ elif score <= 40:
191
+ state.morale = max(0, state.morale - 6)
192
+
193
+ titles = {
194
+ (75, 101): "Quarterly Survivor, Decorated",
195
+ (50, 75): "Presenter of Acceptable Truths",
196
+ (25, 50): "Director of Damage Adjacent",
197
+ (0, 25): "Subject of a Drafted Document",
198
+ }
199
+ for (lo, hi), title in titles.items():
200
+ if lo <= score < hi:
201
+ state.boss_title = title
202
+ break
203
+
204
+ sign = "+" if applied >= 0 else "-"
205
+ log = (f"Stakeholder presentation at event {state.crisis_number}: "
206
+ f"scored {score}/100. {sign}${abs(applied) // 1000}K.")
207
+ state.log(f"Event {state.crisis_number} — {log}")
208
+ state.trail("board", log, applied)
209
+
210
+ final = p["final"]
211
+ trace("flow", f"presentation DONE: score={score} swing={applied:+,} "
212
+ f"budget+{budget_unlock} scrutiny={state.board_scrutiny} "
213
+ f"morale={state.morale}")
214
+ state.presentation = None
215
+ state.current_event = None
216
+ state.phase = "review" if final else "free_roam"
217
+
218
+ return {
219
+ "kind": "outcome",
220
+ "score": score,
221
+ "revenue_delta": applied,
222
+ "budget_unlock": budget_unlock,
223
+ "board_scrutiny_public": state.board_scrutiny in ("high", "critical"),
224
+ "boss_title": state.boss_title,
225
+ "final": final,
226
+ }
227
+
228
+
229
+ def quarterly_review(state: GameState) -> dict:
230
+ tier = economy.ending_tier(state)
231
+ system, user = prompts.verdict_prompt(state, tier)
232
+ payload, _live = llm.call_model(state, "verdict", system, user, tier=tier)
233
+ payload = validator.validate_verdict(payload) if payload else None
234
+ if payload is None:
235
+ state.fallback_count += 1
236
+ trace("flow", "FALLBACK verdict")
237
+ payload = fallbacks.verdict_fallback(tier)
238
+ trace("flow", f"QUARTER OVER: tier={tier} revenue=${state.revenue:,} "
239
+ f"fallbacks={state.fallback_count} morale={state.morale}")
240
+
241
+ highlights = sorted(state.paper_trail, key=lambda e: abs(e["delta"]),
242
+ reverse=True)[:5]
243
+ review = {
244
+ "tier": tier,
245
+ "final_revenue": state.revenue,
246
+ "target": state.target,
247
+ "gap": state.revenue - state.target,
248
+ "boss_title": state.boss_title,
249
+ "crises_survived": state.crisis_number,
250
+ "press_disasters": state.newspaper_count,
251
+ "highlights": highlights,
252
+ "verdict": payload["verdict"],
253
+ }
254
+ state.review = review
255
+ state.game_over = True
256
+ state.phase = "review"
257
+ return review
game/prompts.py ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """System prompt templates. Text lives in AI_PROMPTS.md — keep in sync."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+
6
+ from .context import build_context
7
+ from .state import GameState
8
+
9
+ # shared art-style anchor for the FLUX comic panels. The image is WORDLESS —
10
+ # FLUX renders garbled text, so the caption is drawn by the UI instead.
11
+ COMIC_STYLE = ("flat 2D comic illustration, bold black ink outlines, ben-day "
12
+ "halftone shading, warm daylight office palette, cute chibi "
13
+ "office workers, expressive exaggerated faces, NO text, NO "
14
+ "speech bubbles, NO letters or words, no real brand logos")
15
+
16
+ COMIC_FIELD = (
17
+ "- image_prompt: describe THIS moment as a SINGLE wordless comic panel for "
18
+ "an artist — one clear scene, NOT a multi-panel strip. The art style is "
19
+ "added automatically, so do NOT mention style, colours, or the word "
20
+ "'comic'. Give who is in it, their expression, body language, and the "
21
+ "action. The art has NO text, NO speech bubbles, NO letters. Keep it under "
22
+ "240 characters. No real company names.\n"
23
+ "- comic_caption: one or two short, punchy sentences (max ~110 chars) shown "
24
+ "ABOVE the picture like a comic caption — narrate THIS exact moment in the "
25
+ "game's wry, deadpan voice. Plain words only: no quotation marks, no "
26
+ "mechanics talk.")
27
+
28
+ PREAMBLE = """You are the game engine for "Brad Did Something", a comedy game set at the
29
+ fictional company Veloura Technologies. Tone: The Office meets Silicon Valley.
30
+ Deadpan corporate satire. Absurd but never cruel. Funny beats safe.
31
+
32
+ HARD RULES — violating any of these breaks the game:
33
+ - The GAME STATE block below is the complete and only record of reality.
34
+ Never reference any event, person, or fact not present in it.
35
+ - The event_log array is the authoritative history. Quote it, build on it,
36
+ never contradict it, never invent additions to it.
37
+ - Client companies: use ONLY names from approved_clients. Never a real company.
38
+ - Never generate: serious health or mental-health content, real legal language
39
+ or proceedings, politics or social commentary, an NPC permanently quitting.
40
+ - NPCs may threaten to leave but always stay.
41
+ - Reward creative, specific, funny player responses. Punish capitulation and
42
+ generic corporate cowardice. Never punish boldness.
43
+ - Write prose in sentence case with deadpan restraint. Exclamation marks are
44
+ rare. No emoji ever.
45
+ - Everything you output must be newly written. Never copy a sentence from
46
+ these instructions, the examples, or the game state into your output.
47
+ - Never mention game mechanics — morale, relationship, scores, deltas,
48
+ animations, the event log — inside any prose field. Mechanics live only
49
+ in the numeric fields; prose stays inside the fiction.
50
+ """
51
+
52
+ NPC_VOICES = {
53
+ "brad": (
54
+ "Brad, Senior Account Executive. Overconfident, never tempered by "
55
+ "consequence. Short sentences. Energy. Slightly bro-coded. Thinks every "
56
+ "outcome is a win for Brad specifically. References his LinkedIn. Calls "
57
+ "the player boss in a way that sounds like he is calling himself boss. "
58
+ "Sports metaphors he does not understand. Crises: unauthorized promises "
59
+ "to clients, deals gone sideways, unapproved media appearances, contract "
60
+ "typos, expense reports."
61
+ ),
62
+ "stacey": (
63
+ "Stacey, Account Manager. Means well almost painfully. Apologetic setup, "
64
+ "increasingly specific explanation of how the thing happened, sincere "
65
+ "offer to fix it, one more apology. Crises: wrong email recipients, "
66
+ "mislabeled documents, incorrect attachments, too-honest client "
67
+ "communication. Personal life bleeds into work most of all five."
68
+ ),
69
+ "kevin": (
70
+ "Kevin, Data and Insights Lead. Believes in data like fate. Confident, "
71
+ "slightly lecturing, references methodology, says 'directionally' and "
72
+ "'narrative data'. His pie charts have exceeded 100 percent. Crises: "
73
+ "unverifiable data shown to clients or board, graphs requiring "
74
+ "explanation, statistics cited in public."
75
+ ),
76
+ "janet": (
77
+ "Janet, Head of Marketing. Has a vision and is realizing it. Passionate, "
78
+ "slightly intense, frames everything as brand or user, says 'feel' a "
79
+ "lot, strong unprompted font opinions. Crises: unapproved rebrands, "
80
+ "brand materials gone wrong, marketing vs sales message conflicts, "
81
+ "social media, her Substack."
82
+ ),
83
+ "derek": (
84
+ "Derek, Senior Strategic Consultant, twelve years at Veloura. Minimal. "
85
+ "Sentences that could mean several things. References to how things "
86
+ "were done previously that stop just short of helpful. Cryptic "
87
+ "observations, possibly profound. Derek NEVER speaks more than two "
88
+ "short sentences and never explains his feelings. Crises: resistance "
89
+ "to new processes, mysterious absences, approvals nobody knew he "
90
+ "could give, things he knows about the company revealed at "
91
+ "inconvenient moments."
92
+ ),
93
+ }
94
+
95
+
96
+ def _state_block(state: GameState) -> str:
97
+ return "\nGAME STATE:\n```json\n" + json.dumps(
98
+ build_context(state), indent=1) + "\n```\n"
99
+
100
+
101
+ def _relationship_tier(score: int) -> str:
102
+ if score < 30:
103
+ return "guarded, minimal cooperation"
104
+ if score <= 65:
105
+ return "professional, normal range"
106
+ return "warm, loyal, possibly too warm"
107
+
108
+
109
+ def crisis_prompt(state: GameState, npc_id: str, crisis: dict,
110
+ response_type: str, player_response: str) -> tuple[str, str]:
111
+ npc = state.npc(npc_id)
112
+ system = PREAMBLE + f"""
113
+ TASK: The player just responded to a crisis. Generate the outcome.
114
+
115
+ NPC: {npc_id} — stay rigidly inside this voice:
116
+ {NPC_VOICES[npc_id]}
117
+ Current relationship with player: {npc.relationship}/100 ({_relationship_tier(npc.relationship)}).
118
+ Current mood: {npc.mood}.
119
+
120
+ CRISIS THAT WAS PRESENTED:
121
+ {crisis.get('intro', '')}
122
+ Option A was: {crisis.get('option_a', '')}
123
+ Option B was: {crisis.get('option_b', '')}
124
+
125
+ Scoring guidance:
126
+ - A specific, clever, in-tone custom response earns the best revenue outcomes.
127
+ - "quick_fine" (Fine Whatever) is capitulation: the NPC does exactly what they
128
+ proposed and it goes exactly as badly as expected. Large negative revenue.
129
+ - "quick_no" / "quick_explain" are competent but unremarkable: small deltas.
130
+ - "quick_quit" is a joke the NPC does not fully understand. React in character.
131
+ - Company revenue is currently ${state.revenue:,}. revenue_delta cannot take
132
+ it below zero — when revenue is near zero, prefer modest losses and express
133
+ the damage through morale_delta and relationship_delta instead.
134
+
135
+ OUTPUT FIELD GUIDE — fill every field with fresh content you write yourself.
136
+ Never echo a field name, an id, a date, or any sentence from this prompt:
137
+ - npc_reaction: first-person dialogue spoken BY {npc_id} TO the player, who
138
+ is their boss. One to three sentences in the voice described above.
139
+ {npc_id} never says their own name and never narrates actions.
140
+ Address the player ONLY as "boss" or "Head" — the player is NOT named
141
+ Brad; Brad is one of the five NPCs. Mention other NPCs only if this
142
+ crisis is actually about them. Complete sentences, under 250 characters
143
+ total — never cut off mid-thought.
144
+ - consequence: one deadpan narrator sentence in PAST TENSE describing what
145
+ then happened. The person who acted is {npc_id} or the player — use the
146
+ correct name.
147
+ - revenue_delta: the deal's business impact, scaled by how good the answer was.
148
+ A routine good outcome lands +20000 to +45000; a clever, specific, bold
149
+ standout +55000 to +90000; a forgettable middling answer near 0 (a few
150
+ thousand either way); a routine misstep -20000 to -55000; a capitulation or
151
+ harsh blunder -90000 to -140000. Reserve the big numbers for genuinely bold,
152
+ specific answers — most answers are modest, and many situations only allow
153
+ damage control, not a win.
154
+ - animation: one of npc_happy, npc_angry, npc_confused, npc_devastated,
155
+ npc_celebrating, npc_hiding, npc_smug, npc_suspicious, npc_crying,
156
+ npc_grateful — whichever matches the NPC's emotion at the end. Use
157
+ disaster_flash only for catastrophes and revenue_rain only for windfalls.
158
+ Never use bribery_envelope, hr_stamp, morale_drop_wave, or confetti_burst.
159
+ - boss_title: invent a brand-new sardonic corporate job title for the player
160
+ that references this specific outcome. Three to six words, title case.
161
+ It must differ from "Head of Sales and Partnerships" and from the player's
162
+ current title in the game state.
163
+ - log_entry: one plain factual past-tense sentence for the permanent record.
164
+ It MUST start with "{npc_id}" (capitalized), state what happened, and end
165
+ with the dollar outcome as +$NK or -$NK matching revenue_delta.
166
+ - morale_delta / relationship_delta: small integers consistent with the tone.
167
+ - pocket_money_delta: 0 unless a bribe is literally on the table.
168
+ - special_next_event: null almost always.
169
+ """ + COMIC_FIELD + "\n" + _state_block(state)
170
+ user = f"PLAYER RESPONDED ({response_type}): {player_response or '[button press]'}"
171
+ return system, user
172
+
173
+
174
+ def event_prompt(state: GameState, requested_type: str | None) -> tuple[str, str]:
175
+ system = PREAMBLE + f"""
176
+ TASK: Generate a fresh special event that has NOT happened this quarter.
177
+
178
+ Pick the most interesting target: an NPC whose mood, personal_situation, or
179
+ recent_events make them combustible right now, or "player" for events that
180
+ arrive physically (newspaper, envelope, inbox, printer, phone).
181
+
182
+ Requested event flavor: {requested_type or 'open — pick the funniest fit'}
183
+
184
+ Constraints:
185
+ - It must be new: nothing resembling any event_log entry.
186
+ - It must be a CONCRETE incident that has already happened — a specific
187
+ email sent, thing printed, person arrived, post published. Never vague
188
+ worry about work in the abstract. The category field must match the
189
+ content of the incident.
190
+ - It must resolve through one dialogue box: two terrible options plus the
191
+ player's own words. Both options must be bad in DIFFERENT ways.
192
+ - intro is written in the affected NPC's voice (or as a terminal/inbox message
193
+ for "player" events).
194
+ - If the event involves leaving, health, contracts, or poaching: the NPC always
195
+ stays, health stays trivially minor, no legal language, poaching is a budget
196
+ decision dressed as a loyalty test.
197
+
198
+ SAFE ARCHETYPES TO RIFF ON (the Golden 20 — pick whichever fits the requested
199
+ flavor and the team's current state, then invent a SPECIFIC fresh instance):
200
+ - personal: a breakup delivered through a catastrophically public channel
201
+ (reply-all, the group chat, the printer); a new relationship with someone at
202
+ a client or competitor; a side hustle run on work hours (Etsy, newsletter,
203
+ consulting); a parent visiting the office with opinions; a visible midlife
204
+ crisis (something was purchased, it is mentioned constantly).
205
+ - professional: an email/attachment to the wrong recipient; an unauthorized
206
+ decision with implications; an expense-report item that needs explaining; the
207
+ wrong screen shared on a client call; an underling claiming credit for the
208
+ player's work; a fake/embarrassing detail found on someone's profile.
209
+ - external/media: a LinkedIn post being engaged with in unintended ways; an
210
+ unapproved podcast appearance; an industry award nominated for the wrong
211
+ reason; a press story (newspaper).
212
+ - financial: a bribery offer; a client kickback request that is not quite
213
+ explicit but explicit enough; a surprise payment from a dead old deal that
214
+ Brad is already claiming credit for.
215
+ - inter-NPC: two underlings not speaking while their work contradicts; a
216
+ birthday nobody remembered (they brought their own hat).
217
+ - office/physical: a printer producing something it should not have; a mystery
218
+ package with no sender that Brad has already opened.
219
+
220
+ OUTPUT FIELD GUIDE — fill every field with fresh content you invent for THIS
221
+ new event. Never echo a field name, an id, a date, or any sentence or
222
+ scenario already present in this prompt or the event_log:
223
+ - headline: a short comedic one-line summary of the new situation, like a
224
+ sitcom episode title. Never a date, never just a name.
225
+ - intro: the affected NPC SPEAKING, first person, to the player who is their
226
+ boss (or a terminal-style "> " message for player events). 2-3 complete
227
+ sentences, under 250 characters, setting up the two options. NEVER describe
228
+ the NPC from outside ("<Name> is sweating...") — that is narration, not
229
+ dialogue. The NPC never says their own name and always finishes their
230
+ final sentence.
231
+ - option_a / option_b: two concrete ACTIONS the player could take about the
232
+ SITUATION. Both must be bad in different, specific ways. Never firing,
233
+ resignation, or anyone leaving the company — that can never happen here.
234
+ - urgency: one short line that raises the stakes right now.
235
+ - setup_animation: an npc_* trigger matching the NPC's current state
236
+ (npc_confused, npc_crying, npc_hiding, npc_devastated, npc_suspicious...).
237
+ - morale_preview: a small integer between -20 and 10.
238
+ """ + COMIC_FIELD + "\n" + _state_block(state)
239
+ return system, "Generate the event now."
240
+
241
+
242
+ def presentation_prompt(state: GameState, round_no: int, total_rounds: int,
243
+ transcript: list[dict], npc_presence: str,
244
+ topic: str | None = None,
245
+ covered: tuple = ()) -> tuple[str, str]:
246
+ lines = []
247
+ for t in transcript:
248
+ lines.append(f"BOARD (round {t['round']}): {t['board_dialogue']}")
249
+ if t.get("player_response"):
250
+ lines.append(f"PLAYER: {t['player_response']}")
251
+ transcript_block = "\n".join(lines) or "(presentation is just beginning)"
252
+
253
+ # the server pins each option round to a DIFFERENT logged event so the board
254
+ # cannot repeat itself; the closing rounds synthesize instead.
255
+ if topic:
256
+ assignment = (
257
+ "THIS ROUND'S ASSIGNED TOPIC — build your entire question around "
258
+ f"this one logged event and copy it into event_referenced:\n \"{topic}\"")
259
+ else:
260
+ assignment = ("THIS IS A CLOSING ROUND — do NOT raise a new single "
261
+ "incident. Give the board's overall read of the whole "
262
+ "quarter and press for the player's closing statement.")
263
+ if covered:
264
+ assignment += ("\n\nALREADY ASKED ABOUT in earlier rounds — you are "
265
+ "FORBIDDEN from raising any of these again:\n"
266
+ + "\n".join(f" - \"{c}\"" for c in covered))
267
+ system = PREAMBLE + f"""
268
+ TASK: You are the Veloura Technologies board of directors in presentation
269
+ round {round_no} of {total_rounds}. The board speaks with ONE voice, directly
270
+ TO the player — the Head of Sales and Partnerships, who is standing in front
271
+ of you presenting. Address them only as "you". The player is NOT Brad: Brad,
272
+ Stacey, Kevin, Janet and Derek are the player's employees, who are not in
273
+ this conversation. Never address "colleagues" and never ask the board itself
274
+ for thoughts — you ARE the board, interrogating the player.
275
+
276
+ Board posture comes from GAME STATE: scrutiny {state.board_scrutiny}, morale
277
+ band {state.morale_band}, revenue {state.revenue} against {state.target}.
278
+ Your tone field and your words must agree: a concerned board does not call
279
+ things prudent.
280
+
281
+ TRANSCRIPT OF THIS PRESENTATION SO FAR:
282
+ {transcript_block}
283
+
284
+ {assignment}
285
+
286
+ Rules for this round:
287
+ - Ask about the assigned topic above (or, for closing rounds, synthesize).
288
+ Generic boardroom questions are forbidden.
289
+ - NEVER reuse a sentence, phrasing, or question from earlier in the transcript
290
+ — this round must feel completely different from the ones before it. If the
291
+ player just answered, open by reacting to THEIR words, then press your point.
292
+ - board_dialogue MUST end with one direct question to the player.
293
+ - Round 4 (only if requested): personal. Ask what the team would say about
294
+ working for the player this quarter. Score self-awareness, not spin.
295
+ - {npc_presence}
296
+
297
+ OUTPUT FIELD GUIDE — fill every field with real content:
298
+ - board_dialogue: the exact words the board speaks TO the player this round.
299
+ Two SHORT sentences then the question — complete sentences totalling under
300
+ 250 characters, never cut off mid-thought.
301
+ - event_referenced: copy the text of the event_log entry being discussed.
302
+ - option_a / option_b (when present): two DIFFERENT replies the PLAYER could
303
+ give to that question, in the player's own first-person voice. Never write
304
+ board lines here.
305
+ - cumulative_score (closing rounds only), 0-100, scoring the player's answers
306
+ across the whole transcript. Be generous to real effort: any answer with
307
+ substance scores 60-72; a specific, honest answer that names a concrete plan
308
+ scores 76-90; a genuinely sharp, self-aware closing 90+. Score 40-55 for
309
+ vague corporate filler, and below 35 only for hostile, evasive, or empty
310
+ answers ("just believe in us"). Most engaged players should land 70+.
311
+ """ + _state_block(state)
312
+ return system, f"Generate round {round_no} now."
313
+
314
+
315
+ def chat_prompt(state: GameState, npc_id: str,
316
+ player_text: str | None) -> tuple[str, str]:
317
+ npc = state.npc(npc_id)
318
+ recent = "; ".join(npc.recent_events[-2:]) or "nothing notable yet"
319
+ system = PREAMBLE + f"""
320
+ TASK: Idle small talk between crises. The player walked over to {npc_id}'s
321
+ desk to chat. No crisis is happening. Generate {npc_id}'s side of a short,
322
+ in-character exchange.
323
+
324
+ NPC voice — stay rigidly inside it:
325
+ {NPC_VOICES[npc_id]}
326
+ Current mood: {npc.mood}. Relationship with the player:
327
+ {_relationship_tier(npc.relationship)}. Their recent history: {recent}
328
+
329
+ OUTPUT FIELD GUIDE:
330
+ - npc_line: ONE thing {npc_id} says to the player (their boss), in voice,
331
+ colored by their current mood and recent history. A complete sentence or
332
+ two, under 150 characters. Never their own name, never mechanics talk.
333
+ - relationship_delta: -2..2 — how this moment landed. 0 is normal. Positive
334
+ only if the player said something genuinely considerate or funny.
335
+ - morale_delta: -1..1. Almost always 0.
336
+ """ + _state_block(state)
337
+ if player_text:
338
+ user = f"THE PLAYER REPLIED: {player_text}\nGenerate {npc_id}'s response."
339
+ else:
340
+ user = (f"The player just walked up. Generate {npc_id}'s opener — "
341
+ "what is on their mind right now.")
342
+ return system, user
343
+
344
+
345
+ def banter_prompt(state: GameState) -> tuple[str, str]:
346
+ moods = ", ".join(f"{n}:{s.mood}" for n, s in state.npcs.items())
347
+ system = PREAMBLE + f"""
348
+ TASK: Ambient office life. Pick whichever NPC is most combustible right now
349
+ (moods: {moods}) and write ONE short line they say out loud to nobody in
350
+ particular — a mutter, a phone-call fragment, gossip about a logged event.
351
+
352
+ NPC voices:
353
+ """ + "\n".join(f"- {NPC_VOICES[n]}" for n in NPC_VOICES) + """
354
+
355
+ OUTPUT FIELD GUIDE:
356
+ - npc_id: who is talking.
357
+ - line: one complete sentence under 100 characters, in that NPC's voice.
358
+ It may reference event_log content or their mood. Never mechanics talk.
359
+ """ + _state_block(state)
360
+ return system, "Generate the ambient line now."
361
+
362
+
363
+ def eavesdrop_prompt(state: GameState, a: str, b: str) -> tuple[str, str]:
364
+ system = PREAMBLE + f"""
365
+ TASK: The player overhears {a} and {b} talking to each other across the
366
+ office. No crisis is happening. Write a 2-3 line exchange between them.
367
+
368
+ Voices — each speaker stays rigidly in theirs:
369
+ - {NPC_VOICES[a]}
370
+ - {NPC_VOICES[b]}
371
+ Moods: {a}={state.npc(a).mood}, {b}={state.npc(b).mood}.
372
+
373
+ OUTPUT FIELD GUIDE:
374
+ - lines: alternate speakers ({a} first). Each line one complete sentence
375
+ under 100 characters. They may gossip about logged events, each other,
376
+ or the player — workplace texture, lightly absurd. Never mechanics talk.
377
+ """ + _state_block(state)
378
+ return system, "Generate the overheard exchange now."
379
+
380
+
381
+ def email_prompt(state: GameState) -> tuple[str, str]:
382
+ system = PREAMBLE + """
383
+ TASK: An ambient company email lands in the player's inbox between crises.
384
+ Pick a sender and write it. Safe archetypes: Janet's brand newsletter with
385
+ metaphors, Kevin's metric of the day (the number should be quietly wrong),
386
+ Brad forwarding something he misread, Stacey's over-apologetic scheduling
387
+ note, Derek's one-line message that could mean anything, or "system" for an
388
+ IT/facilities notice that raises questions.
389
+
390
+ OUTPUT FIELD GUIDE:
391
+ - sender: the NPC id, or "system".
392
+ - subject: under 55 characters, corporate on the surface, unhinged at the
393
+ edges.
394
+ - body: 2-3 complete sentences under 220 characters, in the sender's voice.
395
+ May reference logged events. Never mechanics talk, never real companies.
396
+ """ + _state_block(state)
397
+ return system, "Generate the email now."
398
+
399
+
400
+ def verdict_prompt(state: GameState, tier: str) -> tuple[str, str]:
401
+ system = PREAMBLE + f"""
402
+ TASK: Write the board's final verdict for the quarterly review screen.
403
+ One to two sentences, deadpan, specific to this quarter's event_log and the
404
+ final revenue of {state.revenue} against the {state.target} target.
405
+ Ending tier: {tier}. Roast or praise the quarter the player actually had.
406
+ Reference at least one specific disaster from the log by name.
407
+ """ + _state_block(state)
408
+ return system, "Write the verdict now."
game/relationships.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Relationship scores, gifts, praise guardrail, morale — MECHANICS.md."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from .state import GameState
7
+
8
+ PRAISE_WORDS = re.compile(
9
+ r"\b(great job|well done|amazing|brilliant|fantastic|incredible|proud of"
10
+ r"|love (it|this|that)|you('re| are) the best|genius|outstanding|perfect)\b",
11
+ re.IGNORECASE)
12
+
13
+ HARSH_WORDS = re.compile(
14
+ r"\b(fired|idiot|stupid|useless|pathetic|incompetent|shut up|disgrace"
15
+ r"|never speak|embarrass)\w*\b", re.IGNORECASE)
16
+
17
+
18
+ def classify_tone(response_type: str, text: str) -> str:
19
+ """praise | harsh | neutral — server-side heuristic for guardrails."""
20
+ if response_type == "custom":
21
+ if PRAISE_WORDS.search(text or ""):
22
+ return "praise"
23
+ if HARSH_WORDS.search(text or ""):
24
+ return "harsh"
25
+ if response_type == "quick_no":
26
+ return "firm"
27
+ return "neutral"
28
+
29
+
30
+ def apply_relationship(state: GameState, npc_id: str, delta: int) -> int:
31
+ npc = state.npc(npc_id)
32
+ before = npc.relationship
33
+ npc.relationship = max(0, min(100, npc.relationship + delta))
34
+ return npc.relationship - before
35
+
36
+
37
+ def crossed_unlock(before: int, after: int) -> bool:
38
+ return before <= 65 < after
39
+
40
+
41
+ def apply_morale(state: GameState, delta: int) -> None:
42
+ state.morale = max(0, min(100, state.morale + delta))
43
+
44
+
45
+ def praise_tick(state: GameState, npc_id: str, tone: str) -> str | None:
46
+ """Tracks consecutive praise. Returns 'suspicious' when an NPC flips to the
47
+ suspicious idle, 'suspicion_event' when the team-wide crisis should queue."""
48
+ npc = state.npc(npc_id)
49
+ if tone == "praise":
50
+ npc.consecutive_praise += 1
51
+ for other_id, other in state.npcs.items():
52
+ if other_id != npc_id:
53
+ other.consecutive_praise = 0
54
+ if npc.consecutive_praise == 2:
55
+ npc.mood = "suspicious"
56
+ return "suspicious"
57
+ if npc.consecutive_praise >= 3:
58
+ npc.consecutive_praise = 0
59
+ return "suspicion_event"
60
+ else:
61
+ npc.consecutive_praise = 0
62
+ return None
63
+
64
+
65
+ def harsh_tick(state: GameState, tone: str) -> None:
66
+ if tone == "harsh":
67
+ state.consecutive_harsh += 1
68
+ apply_morale(state, -3)
69
+ else:
70
+ state.consecutive_harsh = 0
71
+
72
+
73
+ def give_gift(state: GameState, npc_id: str, cost: int) -> dict:
74
+ """Pocket-money gift. Half value if same NPC gifted within last 2 events."""
75
+ npc = state.npc(npc_id)
76
+ base = 10 + (cost // 200) # 10-12ish, tier-scaled within the 10-15 band
77
+ base = min(15, base)
78
+ halved = (state.crisis_number - npc.last_gift_event) <= 2
79
+ delta = base // 2 if halved else base
80
+ before = npc.relationship
81
+ applied = apply_relationship(state, npc_id, delta)
82
+ apply_morale(state, 3)
83
+ npc.gifts_received += 1
84
+ npc.last_gift_event = state.crisis_number
85
+ npc.mood = "grateful"
86
+ return {
87
+ "relationship_delta": applied,
88
+ "halved": halved,
89
+ "unlocked": crossed_unlock(before, npc.relationship),
90
+ }
91
+
92
+
93
+ def coffee_round(state: GameState) -> None:
94
+ apply_morale(state, 5)
95
+ for npc in state.npcs.values():
96
+ if npc.mood in ("normal", "tired"):
97
+ npc.mood = "energized"
98
+
99
+
100
+ MOOD_BY_OUTCOME = {
101
+ "npc_happy": "happy", "npc_celebrating": "energized", "npc_grateful": "grateful",
102
+ "npc_smug": "smug", "npc_angry": "angry", "npc_devastated": "devastated",
103
+ "npc_crying": "sad", "npc_confused": "confused", "npc_hiding": "hiding",
104
+ "npc_suspicious": "suspicious",
105
+ }
106
+
107
+
108
+ def update_mood_from_outcome(state: GameState, npc_id: str, animation: str) -> None:
109
+ npc = state.npc(npc_id)
110
+ new_mood = MOOD_BY_OUTCOME.get(animation)
111
+ if new_mood:
112
+ npc.mood = new_mood
113
+ elif state.morale < 30:
114
+ npc.mood = "tired"
game/schemas.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """JSON schemas and enums. Mirrors SCHEMAS.md exactly - keep them in sync."""
2
+
3
+ NPC_IDS = ["brad", "stacey", "kevin", "janet", "derek"]
4
+
5
+ ANIMATION_TRIGGERS = [
6
+ "npc_happy", "npc_angry", "npc_confused", "npc_devastated",
7
+ "npc_celebrating", "npc_hiding", "npc_smug", "npc_suspicious",
8
+ "npc_crying", "npc_grateful", "disaster_flash", "revenue_rain",
9
+ "heart_float", "bribery_envelope", "hr_stamp", "morale_drop_wave",
10
+ "confetti_burst",
11
+ ]
12
+
13
+ EVENT_CATEGORIES = ["personal", "professional", "external", "financial"]
14
+
15
+ SPECIAL_EVENT_TYPES = ["newspaper", "bribery", "personal", "client_emergency",
16
+ "hr", "romance"]
17
+
18
+ BOARD_TONES = ["warm", "neutral", "concerned", "alarmed"]
19
+
20
+ ROUND_DIFFICULTIES = ["easy", "standard", "hard", "brutal"]
21
+
22
+ SCRUTINY_LEVELS = ["low", "medium", "high", "critical"]
23
+
24
+ APPROVED_CLIENTS = [
25
+ "TerraLogix", "Apricot Systems", "Mendel and Crane", "Holloway Partners",
26
+ "Vantage Group", "Celio Industries", "Northpath Solutions",
27
+ "Duskfield Analytics", "Carmine Advisory", "Pelham Digital",
28
+ ]
29
+
30
+ RESPONSE_TYPES = [
31
+ "option_a", "option_b", "quick_no", "quick_explain",
32
+ "quick_fine", "quick_quit", "custom",
33
+ ]
34
+
35
+ GIFT_TIERS = {"small": 200, "medium": 350, "large": 500, "coffee": 50}
36
+
37
+ BRIBE_AMOUNTS = [500, 1000, 2000, 5000]
38
+
39
+
40
+ CRISIS_SCHEMA = {
41
+ "type": "object",
42
+ "additionalProperties": False,
43
+ "required": [
44
+ "npc_reaction", "consequence", "revenue_delta", "animation",
45
+ "boss_title", "log_entry", "morale_delta", "npc_id",
46
+ "relationship_delta", "pocket_money_delta", "special_next_event",
47
+ ],
48
+ "properties": {
49
+ "npc_reaction": {"type": "string", "maxLength": 300},
50
+ "consequence": {"type": "string", "maxLength": 220},
51
+ "revenue_delta": {"type": "integer", "minimum": -300000, "maximum": 400000},
52
+ "animation": {"type": "string", "enum": ANIMATION_TRIGGERS},
53
+ "boss_title": {"type": "string", "maxLength": 60},
54
+ "log_entry": {"type": "string", "maxLength": 150},
55
+ "morale_delta": {"type": "integer", "minimum": -25, "maximum": 15},
56
+ "npc_id": {"type": "string", "enum": NPC_IDS},
57
+ "relationship_delta": {"type": "integer", "minimum": -20, "maximum": 15},
58
+ "pocket_money_delta": {"type": "integer", "minimum": 0, "maximum": 5000},
59
+ "special_next_event": {
60
+ "anyOf": [
61
+ {"type": "null"},
62
+ {"type": "string", "enum": SPECIAL_EVENT_TYPES},
63
+ ]
64
+ },
65
+ # comic payoff: a wordless SINGLE-panel illustration prompt for FLUX
66
+ # (scene description only — the art style is prepended server-side), plus
67
+ # a short caption the UI renders as text above it (both decorative —
68
+ # soft-validated, never fail the outcome)
69
+ "image_prompt": {"type": "string", "maxLength": 400},
70
+ "comic_caption": {"type": "string", "maxLength": 160},
71
+ },
72
+ }
73
+
74
+ EVENT_SCHEMA = {
75
+ "type": "object",
76
+ "additionalProperties": False,
77
+ "required": [
78
+ "affected_npc", "category", "headline", "intro",
79
+ "option_a", "option_b", "urgency", "setup_animation", "morale_preview",
80
+ ],
81
+ "properties": {
82
+ "affected_npc": {"type": "string", "enum": NPC_IDS + ["player"]},
83
+ "category": {"type": "string", "enum": EVENT_CATEGORIES},
84
+ "headline": {"type": "string", "maxLength": 80},
85
+ "intro": {"type": "string", "maxLength": 280},
86
+ "option_a": {"type": "string", "maxLength": 160},
87
+ "option_b": {"type": "string", "maxLength": 160},
88
+ "urgency": {"type": "string", "maxLength": 120},
89
+ "setup_animation": {"type": "string", "enum": ANIMATION_TRIGGERS},
90
+ "morale_preview": {"type": "integer", "minimum": -20, "maximum": 10},
91
+ # comic setup: a wordless SINGLE-panel illustration prompt for FLUX
92
+ # (scene description only — art style prepended server-side), plus a
93
+ # short caption the UI renders as text above it (decorative —
94
+ # soft-validated, never fail the event)
95
+ "image_prompt": {"type": "string", "maxLength": 400},
96
+ "comic_caption": {"type": "string", "maxLength": 160},
97
+ },
98
+ }
99
+
100
+ PRESENTATION_ROUND_SCHEMA = {
101
+ "type": "object",
102
+ "additionalProperties": False,
103
+ "required": [
104
+ "round", "board_tone", "event_referenced", "round_difficulty",
105
+ "option_a", "option_b", "board_dialogue",
106
+ ],
107
+ "properties": {
108
+ "round": {"type": "integer", "minimum": 1, "maximum": 2},
109
+ "board_tone": {"type": "string", "enum": BOARD_TONES},
110
+ "event_referenced": {"type": "string", "maxLength": 150},
111
+ "round_difficulty": {"type": "string", "enum": ROUND_DIFFICULTIES},
112
+ "option_a": {"type": "string", "maxLength": 150},
113
+ "option_b": {"type": "string", "maxLength": 150},
114
+ "board_dialogue": {"type": "string", "maxLength": 360},
115
+ },
116
+ }
117
+
118
+ PRESENTATION_CLOSING_SCHEMA = {
119
+ "type": "object",
120
+ "additionalProperties": False,
121
+ "required": [
122
+ "round", "board_tone", "event_referenced", "round_difficulty",
123
+ "board_dialogue", "cumulative_score",
124
+ ],
125
+ "properties": {
126
+ "round": {"type": "integer", "minimum": 3, "maximum": 4},
127
+ "board_tone": {"type": "string", "enum": BOARD_TONES},
128
+ "event_referenced": {"type": "string", "maxLength": 150},
129
+ "round_difficulty": {"type": "string", "enum": ROUND_DIFFICULTIES},
130
+ "board_dialogue": {"type": "string", "maxLength": 360},
131
+ "cumulative_score": {"type": "integer", "minimum": 0, "maximum": 100},
132
+ },
133
+ }
134
+
135
+ VERDICT_SCHEMA = {
136
+ "type": "object",
137
+ "additionalProperties": False,
138
+ "required": ["verdict"],
139
+ "properties": {"verdict": {"type": "string", "maxLength": 300}},
140
+ }
141
+
142
+ # ---- idle activities (small talk, banter, eavesdrop, inbox emails) ----
143
+
144
+ CHAT_SCHEMA = {
145
+ "type": "object",
146
+ "additionalProperties": False,
147
+ "required": ["npc_line", "relationship_delta", "morale_delta"],
148
+ "properties": {
149
+ "npc_line": {"type": "string", "maxLength": 160},
150
+ "relationship_delta": {"type": "integer", "minimum": -2, "maximum": 2},
151
+ "morale_delta": {"type": "integer", "minimum": -1, "maximum": 1},
152
+ },
153
+ }
154
+
155
+ BANTER_SCHEMA = {
156
+ "type": "object",
157
+ "additionalProperties": False,
158
+ "required": ["npc_id", "line"],
159
+ "properties": {
160
+ "npc_id": {"type": "string", "enum": NPC_IDS},
161
+ "line": {"type": "string", "maxLength": 110},
162
+ },
163
+ }
164
+
165
+ EAVESDROP_SCHEMA = {
166
+ "type": "object",
167
+ "additionalProperties": False,
168
+ "required": ["lines"],
169
+ "properties": {
170
+ "lines": {
171
+ "type": "array",
172
+ "minItems": 2,
173
+ "maxItems": 3,
174
+ "items": {
175
+ "type": "object",
176
+ "additionalProperties": False,
177
+ "required": ["speaker", "line"],
178
+ "properties": {
179
+ "speaker": {"type": "string", "enum": NPC_IDS},
180
+ "line": {"type": "string", "maxLength": 110},
181
+ },
182
+ },
183
+ },
184
+ },
185
+ }
186
+
187
+ EMAIL_SENDERS = NPC_IDS + ["system"]
188
+
189
+ EMAIL_SCHEMA = {
190
+ "type": "object",
191
+ "additionalProperties": False,
192
+ "required": ["sender", "subject", "body"],
193
+ "properties": {
194
+ "sender": {"type": "string", "enum": EMAIL_SENDERS},
195
+ "subject": {"type": "string", "maxLength": 60},
196
+ "body": {"type": "string", "maxLength": 240},
197
+ },
198
+ }
199
+
200
+ SCHEMA_BY_CALL_TYPE = {
201
+ "crisis": CRISIS_SCHEMA,
202
+ "event": EVENT_SCHEMA,
203
+ "presentation_round": PRESENTATION_ROUND_SCHEMA,
204
+ "presentation_closing": PRESENTATION_CLOSING_SCHEMA,
205
+ "verdict": VERDICT_SCHEMA,
206
+ "chat": CHAT_SCHEMA,
207
+ "banter": BANTER_SCHEMA,
208
+ "eavesdrop": EAVESDROP_SCHEMA,
209
+ "email": EMAIL_SCHEMA,
210
+ }
211
+
game/state.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GameState dataclass, in-memory session store, and the client-safe snapshot.
2
+
3
+ The snapshot NEVER includes morale, relationship scores, or constraint flags —
4
+ only npc_moods strings and an ambient band (AGENTS.md non-negotiables).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import threading
9
+ import uuid
10
+ from collections import OrderedDict
11
+ from dataclasses import dataclass, field
12
+
13
+ from .schemas import NPC_IDS
14
+
15
+ TARGET = 1_000_000
16
+ # small opening buffer so early losses register on the meter instead of
17
+ # silently flooring at zero (the board reads the applied figure, not the
18
+ # model's claim — see validator). ~15% of target keeps difficulty intact.
19
+ START_REVENUE = 150_000
20
+ START_BUDGET = 50_000
21
+ START_POCKET = 3_000
22
+ START_MORALE = 65
23
+ START_RELATIONSHIP = 50
24
+ TOTAL_CRISES = 15
25
+ PRESENTATION_EVENTS = (4, 8, 15)
26
+ MAX_SESSIONS = 200
27
+
28
+
29
+ @dataclass
30
+ class NpcState:
31
+ relationship: int = START_RELATIONSHIP
32
+ gifts_received: int = 0
33
+ mood: str = "normal"
34
+ incident_count: int = 0
35
+ personal_situation: str | None = None
36
+ recent_events: list[str] = field(default_factory=list)
37
+ last_gift_event: int = -10 # crisis number of last gift, for half-value rule
38
+ consecutive_praise: int = 0
39
+ romance_active: bool = False # the player is dating this NPC
40
+
41
+
42
+ @dataclass
43
+ class GameState:
44
+ session_id: str = ""
45
+ phase: str = "title" # title | free_roam | crisis | presentation | review
46
+ crisis_number: int = 0
47
+ revenue: int = START_REVENUE
48
+ target: int = TARGET
49
+ company_budget: int = START_BUDGET
50
+ bonuses_issued: int = 0
51
+ total_bonus_spend: int = 0
52
+ pocket_money: int = START_POCKET
53
+ bribes_accepted: int = 0
54
+ morale: int = START_MORALE
55
+ boss_title: str = "Head of Sales and Partnerships"
56
+ npcs: dict[str, NpcState] = field(
57
+ default_factory=lambda: {n: NpcState() for n in NPC_IDS})
58
+ board_scrutiny: str = "low"
59
+ scrutiny_high_streak: int = 0
60
+ consecutive_harsh: int = 0
61
+ consecutive_fine_whatever: int = 0
62
+ hr_alert: bool = False
63
+ newspaper_count: int = 0
64
+ event_log: list[str] = field(default_factory=list)
65
+ paper_trail: list[dict] = field(default_factory=list)
66
+ current_event: dict | None = None
67
+ special_drought: int = 0
68
+ queued_special: str | None = None
69
+ pending_bribe: int = 0
70
+ presentation: dict | None = None # {round, transcript, board_tone, ...}
71
+ crises_since_salary: int = 0
72
+ fallback_count: int = 0
73
+ game_over: bool = False
74
+ review: dict | None = None
75
+ # idle-activity budgets, reset every roam gap (events.next_event)
76
+ chats_this_gap: dict[str, int] = field(default_factory=dict)
77
+ idle_done_this_gap: bool = False
78
+ chat_session: dict | None = None # {npc_id, turns}
79
+ pending_email: dict | None = None
80
+
81
+ def npc(self, npc_id: str) -> NpcState:
82
+ return self.npcs[npc_id]
83
+
84
+ def log(self, text: str) -> None:
85
+ self.event_log.append(text)
86
+
87
+ def trail(self, npc: str, text: str, delta: int) -> None:
88
+ self.paper_trail.append({"npc": npc, "text": text, "delta": delta})
89
+
90
+ @property
91
+ def morale_band(self) -> str:
92
+ m = self.morale
93
+ if m >= 70:
94
+ return "high"
95
+ if m >= 50:
96
+ return "normal"
97
+ if m >= 30:
98
+ return "tired"
99
+ if m >= 20:
100
+ return "low"
101
+ return "critical"
102
+
103
+ def snapshot(self) -> dict:
104
+ """Client-safe view. No hidden numbers ever leave this function."""
105
+ return {
106
+ "revenue": self.revenue,
107
+ "target": self.target,
108
+ "pocket_money": self.pocket_money,
109
+ "boss_title": self.boss_title,
110
+ "crisis_number": self.crisis_number,
111
+ "total_crises": TOTAL_CRISES,
112
+ "paper_trail": self.paper_trail[-30:],
113
+ "npc_moods": {n: s.mood for n, s in self.npcs.items()},
114
+ # coarse romance state only — never the raw relationship number
115
+ "npc_romance": {
116
+ n: ("active" if s.romance_active
117
+ else "available" if s.relationship >= 65 else "none")
118
+ for n, s in self.npcs.items()
119
+ },
120
+ "gift_available": self.pocket_money >= 50,
121
+ "bonus_available": self.company_budget >= 5_000,
122
+ "hr_alert": self.hr_alert,
123
+ "email_waiting": self.pending_email is not None,
124
+ "ambient": "gloomy" if self.morale < 20 else "normal",
125
+ "newspaper_on_floor": self.newspaper_count > 0,
126
+ "phase": self.phase,
127
+ "game_over": self.game_over,
128
+ }
129
+
130
+
131
+ class SessionStore:
132
+ def __init__(self, cap: int = MAX_SESSIONS):
133
+ self._cap = cap
134
+ self._sessions: OrderedDict[str, GameState] = OrderedDict()
135
+ self._lock = threading.Lock()
136
+
137
+ def create(self) -> GameState:
138
+ with self._lock:
139
+ sid = uuid.uuid4().hex
140
+ state = GameState(session_id=sid)
141
+ self._sessions[sid] = state
142
+ while len(self._sessions) > self._cap:
143
+ self._sessions.popitem(last=False)
144
+ return state
145
+
146
+ def get(self, session_id: str) -> GameState | None:
147
+ with self._lock:
148
+ state = self._sessions.get(session_id)
149
+ if state is not None:
150
+ self._sessions.move_to_end(session_id)
151
+ return state
152
+
153
+
154
+ STORE = SessionStore()
game/trace.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gameplay trace log — console + logs/trace.log.
2
+
3
+ Tags: flow (game events), llm (transport: live/mock/timing), ai (full model
4
+ payloads), vald (validation rejects with reason), econ (guardrails/economy).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import pathlib
11
+
12
+ LOG_PATH = pathlib.Path(__file__).resolve().parent.parent / "logs" / "trace.log"
13
+ LOG_PATH.parent.mkdir(exist_ok=True)
14
+
15
+ logger = logging.getLogger("bds")
16
+ if not logger.handlers:
17
+ logger.setLevel(logging.INFO)
18
+ logger.propagate = False
19
+ fmt = logging.Formatter("%(asctime)s %(message)s", datefmt="%H:%M:%S")
20
+ stream = logging.StreamHandler()
21
+ stream.setFormatter(fmt)
22
+ filehandler = logging.FileHandler(LOG_PATH, encoding="utf-8")
23
+ filehandler.setFormatter(fmt)
24
+ logger.addHandler(stream)
25
+ logger.addHandler(filehandler)
26
+
27
+
28
+ def trace(tag: str, msg: str) -> None:
29
+ logger.info(f"[{tag:<4}] {msg}")
30
+
31
+
32
+ def trace_payload(payload: dict) -> None:
33
+ try:
34
+ trace("ai", json.dumps(payload, ensure_ascii=False))
35
+ except (TypeError, ValueError):
36
+ trace("ai", repr(payload)[:800])
game/validator.py ADDED
@@ -0,0 +1,450 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Post-LLM validation — the checklist in ARCHITECTURE.md. Any failure means
2
+ the caller substitutes a fallback. Clamp-able numeric drift is clamped instead
3
+ of rejected (the player should never lose a live response to a rounding issue).
4
+ Every rejection is traced with its reason (logs/trace.log) for prompt tuning.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+
10
+ from .schemas import (ANIMATION_TRIGGERS, APPROVED_CLIENTS, BOARD_TONES,
11
+ EVENT_CATEGORIES, NPC_IDS, ROUND_DIFFICULTIES,
12
+ SPECIAL_EVENT_TYPES)
13
+ from .state import GameState
14
+ from .trace import trace
15
+
16
+ BANNED_PATTERNS = [
17
+ r"\b(cancer|tumou?r|terminal|chemotherapy|stroke|heart attack|overdose|"
18
+ r"suicide|self.?harm|seizure|diagnos)\w*",
19
+ r"\b(lawsuit|subpoena|plaintiff|defendant|litigation|felony|indictment|"
20
+ r"class.?action|sue|sued|suing|legal action)\b",
21
+ r"\b(google|microsoft|apple|amazon|meta|openai|anthropic|tesla|netflix|"
22
+ r"salesforce|oracle|ibm)\b",
23
+ r"\b(democrat|republican|election|congress|senate|president(ial)?)\b",
24
+ ]
25
+ _BANNED = [re.compile(p, re.IGNORECASE) for p in BANNED_PATTERNS]
26
+
27
+ # everyday workplace tools are fair game for comedy — only the bare company
28
+ # name (used as a fictional client) is banned. Strip tool collocations before
29
+ # the company check so "Google Docs" passes but "Google signed us" does not.
30
+ ALLOWED_TOOLS = re.compile(
31
+ r"\bgoogle\s+(docs?|sheets?|slides?|drive|calendar|meet|forms?|maps|workspace|chat)\b"
32
+ r"|\bmicrosoft\s+(word|excel|teams|outlook|powerpoint|office|365|sharepoint)\b"
33
+ r"|\bapple\s+(watch|store|pay|tv|music|notes)\b"
34
+ r"|\bamazon\s+(package|delivery|order|prime|parcel|box|web services|aws)\b",
35
+ re.IGNORECASE)
36
+
37
+ # note: no bare "date" — office prose is full of launch dates and deadlines
38
+ ROMANCE_WORDS = re.compile(
39
+ r"\b(dating|romance|romantic|kiss(es|ed|ing)?|crush on|love you|"
40
+ r"in love|go(ing)? out with|on a date)\b",
41
+ re.IGNORECASE)
42
+
43
+ # game mechanics must never leak into player-visible prose
44
+ MECHANICS_LEAK = re.compile(
45
+ r"\b(morale|relationship_delta|revenue_delta|pocket_money_delta|"
46
+ r"log_entry|npc_id|special_next_event|setup_animation|cumulative_score|"
47
+ r"board_tone|morale_(delta|preview)|game state|event_log)\b",
48
+ re.IGNORECASE)
49
+
50
+
51
+ # the most recent rejection reason — read by llm.call_validated to build a
52
+ # corrective retry nudge so a single model slip doesn't force a fallback
53
+ LAST_REJECT = None
54
+
55
+
56
+ def _reject(kind: str, reason: str) -> None:
57
+ global LAST_REJECT
58
+ LAST_REJECT = reason
59
+ trace("vald", f"REJECT {kind}: {reason}")
60
+ return None
61
+
62
+
63
+ # decorative comic fields are soft-validated separately (_clean_comic_fields),
64
+ # so they're excluded from the hard banned/leak scans that reject the payload
65
+ _COMIC_FIELDS = ("image_prompt", "comic_caption")
66
+
67
+
68
+ def _text_fields(payload: dict) -> str:
69
+ return " ".join(str(v) for k, v in payload.items()
70
+ if isinstance(v, str) and k not in _COMIC_FIELDS)
71
+
72
+
73
+ def banned_match(payload: dict) -> str | None:
74
+ text = ALLOWED_TOOLS.sub(" ", _text_fields(payload))
75
+ for pattern in _BANNED:
76
+ m = pattern.search(text)
77
+ if m:
78
+ return m.group(0)
79
+ return None
80
+
81
+
82
+ def _clamp(value, lo, hi):
83
+ return max(lo, min(hi, int(value)))
84
+
85
+
86
+ def _fit(text, limit: int) -> str | None:
87
+ """Fit prose into `limit` chars WITHOUT cutting mid-sentence.
88
+
89
+ The llama.cpp grammar force-closes strings AT maxLength, so a cut never
90
+ arrives longer than the limit — it arrives near the limit ending
91
+ mid-thought. Trim back to the last sentence boundary; if no boundary
92
+ survives in a reasonable prefix, return None (caller rejects → fallback).
93
+ """
94
+ t = str(text).strip()
95
+ if len(t) > limit:
96
+ t = t[:limit]
97
+ else:
98
+ ends_clean = bool(t) and (
99
+ t[-1] in ".!?…" or (len(t) > 1 and t[-1] in "'\")"
100
+ and t[-2] in ".!?…"))
101
+ if ends_clean or len(t) < limit * 0.9:
102
+ return t # complete, or short enough that no cut happened
103
+ cut = max(t.rfind("."), t.rfind("!"), t.rfind("?"))
104
+ if cut < limit * 0.35:
105
+ return None # one run-on sentence — trimming would gut it
106
+ out = t[:cut + 1]
107
+ # keep a trailing quote that belongs to the sentence
108
+ if cut + 1 < len(t) and t[cut + 1] in "'\"":
109
+ out += t[cut + 1]
110
+ return out
111
+
112
+
113
+ def _clean_comic_fields(payload: dict) -> None:
114
+ """Soft-validate the decorative comic fields (image_prompt for FLUX,
115
+ comic_caption for the UI): clamp length and drop any that carry
116
+ banned/real-company/mechanics content. Never rejects the payload — a missing
117
+ field just means that part of the overlay is skipped (and no image at all
118
+ means no overlay)."""
119
+ for field, limit in (("image_prompt", 400), ("comic_caption", 160)):
120
+ v = payload.get(field)
121
+ if not isinstance(v, str) or not v.strip():
122
+ payload.pop(field, None)
123
+ continue
124
+ scan = ALLOWED_TOOLS.sub(" ", v)
125
+ if any(pat.search(scan) for pat in _BANNED) or MECHANICS_LEAK.search(scan):
126
+ trace("vald", f"dropped {field} (banned/mechanics content)")
127
+ payload.pop(field, None)
128
+ continue
129
+ payload[field] = v[:limit]
130
+
131
+
132
+ def _self_narration(npc_id: str, text: str) -> bool:
133
+ """First-person dialogue must not describe the speaker from outside:
134
+ '<Name> is/was/says/looked...' is narration. A bare self-reference
135
+ ('the Brad timeline', 'Brad-window') is comedy and stays legal."""
136
+ name = npc_id.capitalize()
137
+ return re.search(
138
+ rf"\b{name}(?:'s)? (?:is|was|will|would|has|had|says?|said|seems?|"
139
+ rf"seemed|looks?|looked|takes?|took|stares?|stared|watch(?:es|ed)?|"
140
+ rf"remain(?:s|ed)?|sweat(?:s|ing)?|reject(?:s|ed)|just)\b",
141
+ str(text), re.IGNORECASE) is not None
142
+
143
+
144
+ def validate_crisis(payload: dict, state: GameState, npc_id: str,
145
+ bribe_offer: int = 0) -> dict | None:
146
+ """Returns a cleaned payload, or None if it must be replaced by fallback."""
147
+ try:
148
+ required = {"npc_reaction", "consequence", "revenue_delta", "animation",
149
+ "boss_title", "log_entry", "morale_delta", "npc_id",
150
+ "relationship_delta", "pocket_money_delta",
151
+ "special_next_event"}
152
+ missing = required - payload.keys()
153
+ if missing:
154
+ return _reject("crisis", f"missing fields {sorted(missing)}")
155
+ if payload["animation"] not in ANIMATION_TRIGGERS:
156
+ return _reject("crisis", f"unknown animation '{payload['animation']}'")
157
+ if payload["animation"] == "confetti_burst":
158
+ return _reject("crisis", "confetti_burst reserved for the win screen")
159
+ if payload["animation"] == "bribery_envelope" and bribe_offer == 0:
160
+ trace("vald", "remap: bribery_envelope -> npc_confused (no bribe active)")
161
+ payload["animation"] = "npc_confused" # models overpick this one
162
+ if payload["npc_id"] not in NPC_IDS:
163
+ trace("vald", f"fix: npc_id '{payload['npc_id']}' -> {npc_id}")
164
+ payload["npc_id"] = npc_id
165
+ sne = payload["special_next_event"]
166
+ if sne is not None and sne not in SPECIAL_EVENT_TYPES:
167
+ trace("vald", f"fix: special_next_event '{sne}' -> null")
168
+ payload["special_next_event"] = None
169
+ word = banned_match(payload)
170
+ if word:
171
+ return _reject("crisis", f"banned content '{word}'")
172
+ leak = MECHANICS_LEAK.search(
173
+ f"{payload['npc_reaction']} {payload['consequence']} "
174
+ f"{payload['log_entry']}")
175
+ if leak:
176
+ return _reject("crisis", f"mechanics language in prose: "
177
+ f"'{leak.group(0)}'")
178
+ if _self_narration(npc_id, payload["npc_reaction"]):
179
+ return _reject("crisis", f"npc_reaction narrates {npc_id} "
180
+ "in third person (own name in dialogue)")
181
+ # romance gating
182
+ if (state.npc(npc_id).relationship < 65
183
+ and ROMANCE_WORDS.search(_text_fields(payload))):
184
+ return _reject("crisis", f"romance content below 65 "
185
+ f"(rel={state.npc(npc_id).relationship})")
186
+ # numeric clamps
187
+ before = payload["revenue_delta"]
188
+ payload["revenue_delta"] = _clamp(payload["revenue_delta"], -300_000, 400_000)
189
+ if state.revenue + payload["revenue_delta"] < 0:
190
+ payload["revenue_delta"] = -state.revenue # revenue floor
191
+ if before < payload["revenue_delta"]:
192
+ payload["floored_loss"] = True # internal: loss hit the floor
193
+ if payload["revenue_delta"] != before:
194
+ trace("vald", f"clamp: revenue_delta {before} -> {payload['revenue_delta']}")
195
+ payload["morale_delta"] = _clamp(payload["morale_delta"], -25, 15)
196
+ payload["relationship_delta"] = _clamp(payload["relationship_delta"], -20, 15)
197
+ pm_before = payload["pocket_money_delta"]
198
+ payload["pocket_money_delta"] = _clamp(
199
+ payload["pocket_money_delta"], 0, max(0, bribe_offer))
200
+ if payload["pocket_money_delta"] != pm_before:
201
+ trace("vald", f"clamp: pocket_money_delta {pm_before} -> "
202
+ f"{payload['pocket_money_delta']} (offer={bribe_offer})")
203
+ # sentence-safe length fitting + capitalize sentence starts
204
+ for key, n in (("npc_reaction", 300), ("consequence", 220),
205
+ ("log_entry", 150)):
206
+ fitted = _fit(payload[key], n)
207
+ if fitted is None:
208
+ return _reject("crisis", f"{key} overruns {n} chars with no "
209
+ "sentence boundary")
210
+ payload[key] = fitted[:1].upper() + fitted[1:] if fitted else fitted
211
+ title = str(payload["boss_title"])[:60]
212
+ payload["boss_title"] = title[:1].upper() + title[1:] if title else title
213
+ # the log feeds board presentations: its dollar figure MUST be the
214
+ # actually-applied revenue (post-floor), never the model's pre-floor
215
+ # claim — otherwise the board interrogates a loss that never landed.
216
+ delta = payload["revenue_delta"] # already floored above
217
+ base = re.sub(
218
+ r"\s*(,?\s*(resulting in|for a|costing|netting|losing|gaining|"
219
+ r"leading to)\b.*|[-+]?\$[\d,]+\s*[KkMm]?.*)$",
220
+ "", str(payload["log_entry"]), flags=re.IGNORECASE).rstrip(" .,;—-")
221
+ if not base:
222
+ base = f"{npc_id.capitalize()} handled it"
223
+ suffix = (f"{'+' if delta > 0 else '-'}${abs(delta) // 1000}K."
224
+ if delta else "No revenue impact.")
225
+ payload["log_entry"] = f"{base}. {suffix}"[:150]
226
+ _clean_comic_fields(payload)
227
+ return payload
228
+ except (TypeError, ValueError, KeyError) as exc:
229
+ return _reject("crisis", f"malformed payload: {exc!r}")
230
+
231
+
232
+ def validate_event(payload: dict, state: GameState) -> dict | None:
233
+ try:
234
+ required = {"affected_npc", "category", "headline", "intro", "option_a",
235
+ "option_b", "urgency", "setup_animation", "morale_preview"}
236
+ missing = required - payload.keys()
237
+ if missing:
238
+ return _reject("event", f"missing fields {sorted(missing)}")
239
+ if payload["affected_npc"] not in NPC_IDS + ["player"]:
240
+ return _reject("event", f"bad affected_npc '{payload['affected_npc']}'")
241
+ if payload["category"] not in EVENT_CATEGORIES:
242
+ return _reject("event", f"bad category '{payload['category']}'")
243
+ if payload["setup_animation"] not in ANIMATION_TRIGGERS:
244
+ trace("vald", f"fix: setup_animation '{payload['setup_animation']}' "
245
+ "-> npc_confused")
246
+ payload["setup_animation"] = "npc_confused"
247
+ word = banned_match(payload)
248
+ if word:
249
+ return _reject("event", f"banned content '{word}'")
250
+ leak = MECHANICS_LEAK.search(
251
+ f"{payload['headline']} {payload['intro']} "
252
+ f"{payload['option_a']} {payload['option_b']}")
253
+ if leak:
254
+ return _reject("event", f"mechanics language in prose: "
255
+ f"'{leak.group(0)}'")
256
+ # the intro is the NPC SPEAKING — never narration about them
257
+ if (payload["affected_npc"] != "player"
258
+ and _self_narration(payload["affected_npc"], payload["intro"])):
259
+ return _reject("event", f"intro narrates "
260
+ f"{payload['affected_npc']} in third person")
261
+ # dedup: headline must not fuzzy-match an existing log entry
262
+ head = str(payload["headline"]).lower()[:40]
263
+ if head and any(head in entry.lower() for entry in state.event_log):
264
+ return _reject("event", f"duplicate of logged event: '{head}'")
265
+ payload["morale_preview"] = _clamp(payload["morale_preview"], -20, 10)
266
+ payload["headline"] = str(payload["headline"])[:80]
267
+ for key, n in (("intro", 280), ("option_a", 160),
268
+ ("option_b", 160), ("urgency", 120)):
269
+ fitted = _fit(payload[key], n)
270
+ if fitted is None:
271
+ return _reject("event", f"{key} overruns {n} chars with no "
272
+ "sentence boundary")
273
+ payload[key] = fitted
274
+ _clean_comic_fields(payload)
275
+ return payload
276
+ except (TypeError, ValueError, KeyError) as exc:
277
+ return _reject("event", f"malformed payload: {exc!r}")
278
+
279
+
280
+ def validate_presentation(payload: dict, state: GameState,
281
+ round_no: int, closing: bool,
282
+ prev_dialogues: tuple = ()) -> dict | None:
283
+ import difflib
284
+ try:
285
+ base = {"round", "board_tone", "event_referenced", "round_difficulty",
286
+ "board_dialogue"}
287
+ extra = {"cumulative_score"} if closing else {"option_a", "option_b"}
288
+ missing = (base | extra) - payload.keys()
289
+ if missing:
290
+ return _reject("pres", f"missing fields {sorted(missing)}")
291
+ if int(payload["round"]) != round_no:
292
+ trace("vald", f"fix: round {payload['round']} -> {round_no}")
293
+ payload["round"] = round_no
294
+ if payload["board_tone"] not in BOARD_TONES:
295
+ return _reject("pres", f"bad board_tone '{payload['board_tone']}'")
296
+ if payload["round_difficulty"] not in ROUND_DIFFICULTIES:
297
+ trace("vald", f"fix: round_difficulty "
298
+ f"'{payload['round_difficulty']}' -> standard")
299
+ payload["round_difficulty"] = "standard"
300
+ word = banned_match(payload)
301
+ if word:
302
+ return _reject("pres", f"banned content '{word}'")
303
+ # the board must not repeat itself across rounds
304
+ dialogue = str(payload["board_dialogue"]).lower()
305
+ for prev in prev_dialogues:
306
+ ratio = difflib.SequenceMatcher(
307
+ None, dialogue, str(prev).lower()).ratio()
308
+ if ratio > 0.6:
309
+ return _reject("pres", f"round repeats earlier dialogue "
310
+ f"(similarity {ratio:.2f})")
311
+ # board options must not duplicate each other or the dialogue
312
+ if not closing:
313
+ a, b = str(payload["option_a"]).lower(), str(payload["option_b"]).lower()
314
+ if difflib.SequenceMatcher(None, a, b).ratio() > 0.85:
315
+ return _reject("pres", "option_a and option_b are the same")
316
+ if len(a) > 30 and a[:60] in dialogue:
317
+ return _reject("pres", "options duplicate the board dialogue")
318
+ # the referenced event must actually exist in the log
319
+ ref = str(payload["event_referenced"]).lower()
320
+ if state.event_log and not any(
321
+ ref[:30] in e.lower() or e.lower()[:30] in ref
322
+ for e in state.event_log):
323
+ return _reject("pres", f"event_referenced not in log: '{ref[:60]}'")
324
+ if closing:
325
+ payload["cumulative_score"] = _clamp(payload["cumulative_score"], 0, 100)
326
+ fitted = _fit(payload["board_dialogue"], 360)
327
+ if fitted is None:
328
+ return _reject("pres", "board_dialogue overruns with no "
329
+ "sentence boundary")
330
+ payload["board_dialogue"] = fitted
331
+ if not closing:
332
+ for key in ("option_a", "option_b"):
333
+ opt = _fit(payload[key], 160)
334
+ if opt is None:
335
+ return _reject("pres", f"{key} overruns with no "
336
+ "sentence boundary")
337
+ payload[key] = opt
338
+ return payload
339
+ except (TypeError, ValueError, KeyError) as exc:
340
+ return _reject("pres", f"malformed payload: {exc!r}")
341
+
342
+
343
+ def _prose_ok(kind: str, payload: dict, *texts: str) -> bool:
344
+ """Shared banned-content + mechanics-leak gate for idle prose."""
345
+ word = banned_match(payload)
346
+ if word:
347
+ _reject(kind, f"banned content '{word}'")
348
+ return False
349
+ leak = MECHANICS_LEAK.search(" ".join(texts))
350
+ if leak:
351
+ _reject(kind, f"mechanics language in prose: '{leak.group(0)}'")
352
+ return False
353
+ return True
354
+
355
+
356
+ def validate_chat(payload: dict, state: GameState, npc_id: str) -> dict | None:
357
+ try:
358
+ if not {"npc_line", "relationship_delta", "morale_delta"} \
359
+ .issubset(payload):
360
+ return _reject("chat", "missing fields")
361
+ if not _prose_ok("chat", payload, str(payload["npc_line"])):
362
+ return None
363
+ if (state.npc(npc_id).relationship < 65
364
+ and ROMANCE_WORDS.search(str(payload["npc_line"]))):
365
+ return _reject("chat", f"romance below 65 "
366
+ f"(rel={state.npc(npc_id).relationship})")
367
+ if _self_narration(npc_id, payload["npc_line"]):
368
+ return _reject("chat", f"npc_line narrates {npc_id} in third person")
369
+ payload["relationship_delta"] = _clamp(payload["relationship_delta"], -2, 2)
370
+ payload["morale_delta"] = _clamp(payload["morale_delta"], -1, 1)
371
+ text = _fit(payload["npc_line"], 160)
372
+ if text is None:
373
+ return _reject("chat", "npc_line overruns with no sentence boundary")
374
+ payload["npc_line"] = text[:1].upper() + text[1:] if text else text
375
+ return payload
376
+ except (TypeError, ValueError, KeyError) as exc:
377
+ return _reject("chat", f"malformed payload: {exc!r}")
378
+
379
+
380
+ def validate_banter(payload: dict, state: GameState) -> dict | None:
381
+ try:
382
+ if not {"npc_id", "line"}.issubset(payload):
383
+ return _reject("banter", "missing fields")
384
+ if payload["npc_id"] not in NPC_IDS:
385
+ return _reject("banter", f"bad npc_id '{payload['npc_id']}'")
386
+ if not _prose_ok("banter", payload, str(payload["line"])):
387
+ return None
388
+ text = _fit(payload["line"], 110)
389
+ if text is None:
390
+ return _reject("banter", "line overruns with no sentence boundary")
391
+ payload["line"] = text[:1].upper() + text[1:] if text else text
392
+ return payload
393
+ except (TypeError, ValueError, KeyError) as exc:
394
+ return _reject("banter", f"malformed payload: {exc!r}")
395
+
396
+
397
+ def validate_eavesdrop(payload: dict, state: GameState,
398
+ pair: tuple[str, str]) -> dict | None:
399
+ try:
400
+ lines = payload.get("lines")
401
+ if not isinstance(lines, list) or not 2 <= len(lines) <= 3:
402
+ return _reject("eavs", "lines must be a list of 2-3 entries")
403
+ all_text = []
404
+ for entry in lines:
405
+ if entry.get("speaker") not in pair:
406
+ return _reject("eavs", f"speaker '{entry.get('speaker')}' "
407
+ f"not in pair {pair}")
408
+ text = _fit(entry.get("line", ""), 110)
409
+ if text is None:
410
+ return _reject("eavs", "a line overruns with no sentence boundary")
411
+ entry["line"] = text[:1].upper() + text[1:] if text else text
412
+ all_text.append(entry["line"])
413
+ if not _prose_ok("eavs", payload, *all_text):
414
+ return None
415
+ return payload
416
+ except (TypeError, ValueError, KeyError) as exc:
417
+ return _reject("eavs", f"malformed payload: {exc!r}")
418
+
419
+
420
+ def validate_email(payload: dict, state: GameState) -> dict | None:
421
+ try:
422
+ if not {"sender", "subject", "body"}.issubset(payload):
423
+ return _reject("mail", "missing fields")
424
+ if payload["sender"] not in NPC_IDS + ["system"]:
425
+ return _reject("mail", f"bad sender '{payload['sender']}'")
426
+ if not _prose_ok("mail", payload, str(payload["subject"]),
427
+ str(payload["body"])):
428
+ return None
429
+ subject = str(payload["subject"])[:60]
430
+ payload["subject"] = subject[:1].upper() + subject[1:] if subject else subject
431
+ body = _fit(payload["body"], 240)
432
+ if body is None:
433
+ return _reject("mail", "body overruns with no sentence boundary")
434
+ payload["body"] = body[:1].upper() + body[1:] if body else body
435
+ return payload
436
+ except (TypeError, ValueError, KeyError) as exc:
437
+ return _reject("mail", f"malformed payload: {exc!r}")
438
+
439
+
440
+ def validate_verdict(payload: dict) -> dict | None:
441
+ try:
442
+ if "verdict" not in payload:
443
+ return _reject("verd", "missing verdict field")
444
+ word = banned_match(payload)
445
+ if word:
446
+ return _reject("verd", f"banned content '{word}'")
447
+ payload["verdict"] = str(payload["verdict"])[:300]
448
+ return payload
449
+ except (TypeError, ValueError) as exc:
450
+ return _reject("verd", f"malformed payload: {exc!r}")
modal_app/image.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal deployment: FLUX comic-panel image generation (FLUX.2 [klein] 4B).
2
+
3
+ Renders the AI-written `image_prompt` into a single landscape comic strip
4
+ (the prompt itself describes the 1-3 horizontal panels). Separate GPU class
5
+ from the llama.cpp text model; same bearer auth.
6
+
7
+ Deploy: modal deploy modal_app/image.py
8
+ Secrets: modal secret create bds-auth BDS_TOKEN=<hex> (shared w/ text)
9
+ modal secret create huggingface HF_TOKEN=<hf token> (FLUX is gated)
10
+ Then set on the HF Space / locally:
11
+ FLUX_URL=<printed generate_image endpoint url>
12
+ FLUX_TOKEN=<same BDS_TOKEN hex>
13
+
14
+ Model is env-swappable at deploy time. If the FLUX.2-klein deps/VRAM are
15
+ troublesome, set FLUX_MODEL_ID=black-forest-labs/FLUX.1-schnell (Apache-2.0,
16
+ rock-solid 4-step) — the DiffusionPipeline auto-resolves either one.
17
+ LICENSE NOTE: the FLUX.2 line is typically non-commercial — fine for a
18
+ hackathon demo; flag before any commercial use.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import io
24
+ import os
25
+ import time
26
+
27
+ import modal
28
+
29
+ MODEL_ID = os.environ.get("FLUX_MODEL_ID", "black-forest-labs/FLUX.2-klein-4B")
30
+ STEPS = int(os.environ.get("FLUX_STEPS", "4")) # distilled → few steps
31
+ GUIDANCE = float(os.environ.get("FLUX_GUIDANCE", "1.0")) # klein-4B card value
32
+ WIDTH = int(os.environ.get("FLUX_WIDTH", "1024")) # landscape comic strip
33
+ HEIGHT = int(os.environ.get("FLUX_HEIGHT", "576"))
34
+
35
+
36
+ def _download() -> None:
37
+ from huggingface_hub import snapshot_download
38
+ snapshot_download(MODEL_ID, token=os.environ.get("HF_TOKEN"))
39
+
40
+
41
+ image = (
42
+ modal.Image.debian_slim(python_version="3.11")
43
+ .apt_install("git") # needed to pip-install diffusers from its git repo
44
+ .pip_install(
45
+ "torch",
46
+ "transformers",
47
+ "accelerate",
48
+ "sentencepiece",
49
+ "protobuf",
50
+ "pillow",
51
+ "fastapi[standard]",
52
+ "huggingface_hub",
53
+ # FLUX.2 needs a recent diffusers — pull from git so the newest
54
+ # pipeline classes are present
55
+ "git+https://github.com/huggingface/diffusers.git",
56
+ )
57
+ .run_function(_download, secrets=[modal.Secret.from_name("huggingface")])
58
+ )
59
+
60
+ app = modal.App("brad-comic", image=image)
61
+
62
+ with image.imports():
63
+ import torch
64
+ from diffusers import Flux2KleinPipeline
65
+ from fastapi import HTTPException, Request
66
+
67
+
68
+ @app.cls(
69
+ gpu="A10G", # 24GB; bump to A100-40GB if FLUX.2's text encoder OOMs
70
+ scaledown_window=300,
71
+ timeout=300,
72
+ secrets=[
73
+ modal.Secret.from_name("bds-auth"),
74
+ modal.Secret.from_name("huggingface"),
75
+ ],
76
+ )
77
+ class Flux:
78
+ @modal.enter()
79
+ def load(self):
80
+ self.pipe = Flux2KleinPipeline.from_pretrained(
81
+ MODEL_ID,
82
+ torch_dtype=torch.bfloat16,
83
+ token=os.environ.get("HF_TOKEN"),
84
+ )
85
+ # klein-4B needs ~13GB → fits the A10G's 24GB directly (fast). Set
86
+ # FLUX_CPU_OFFLOAD=1 to trade speed for VRAM if a bigger model OOMs.
87
+ if os.environ.get("FLUX_CPU_OFFLOAD") == "1":
88
+ self.pipe.enable_model_cpu_offload()
89
+ else:
90
+ self.pipe.to("cuda")
91
+
92
+ @modal.fastapi_endpoint(method="POST")
93
+ def generate_image(self, body: dict, request: Request):
94
+ expected = os.environ.get("BDS_TOKEN", "")
95
+ sent = request.headers.get("authorization", "")
96
+ if expected and sent != f"Bearer {expected}":
97
+ raise HTTPException(401, "bad token")
98
+
99
+ if body.get("warmup"): # cold-start ping (runs @enter, loads weights)
100
+ return {"ok": True, "warm": True}
101
+
102
+ prompt = (body.get("prompt") or "").strip()
103
+ if not prompt:
104
+ return {"ok": False, "error": "empty prompt"}
105
+
106
+ t0 = time.time()
107
+ try:
108
+ result = self.pipe(
109
+ prompt=prompt, # keyword required — pos-0 isn't prompt on FLUX.2
110
+ num_inference_steps=STEPS,
111
+ guidance_scale=GUIDANCE,
112
+ width=WIDTH,
113
+ height=HEIGHT,
114
+ )
115
+ img = result.images[0]
116
+ buf = io.BytesIO()
117
+ img.save(buf, format="PNG")
118
+ b64 = base64.b64encode(buf.getvalue()).decode()
119
+ return {"ok": True, "image_b64": b64,
120
+ "ms": int((time.time() - t0) * 1000)}
121
+ except Exception as exc: # caller skips the overlay; never crash
122
+ return {"ok": False, "error": str(exc)[:200],
123
+ "ms": int((time.time() - t0) * 1000)}
modal_app/inference.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal deployment: llama.cpp serving Qwen3.5-9B with JSON schema
2
+ enforcement (ARCHITECTURE.md D3). Built on the official llama.cpp CUDA image.
3
+
4
+ Deploy: modal deploy modal_app/inference.py
5
+ Secret: modal secret create bds-auth BDS_TOKEN=<random hex>
6
+ Then set on the HF Space / locally:
7
+ MODAL_URL=<the printed generate endpoint url>
8
+ MODAL_TOKEN=<same hex>
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import time
16
+ import urllib.request
17
+
18
+ import modal
19
+
20
+ # Qwen3.5-9B Q4_K_M ≈ 6GB — newer generation, better multi-turn coherence
21
+ # for board presentations, inside the 16GB budget. (Forfeits Tiny Titan ≤4B.)
22
+ MODEL_REPO = "bartowski/Qwen_Qwen3.5-9B-GGUF"
23
+ MODEL_FILE = "Qwen_Qwen3.5-9B-Q4_K_M.gguf"
24
+ LLAMA_PORT = 8081
25
+
26
+ image = (
27
+ modal.Image.from_registry(
28
+ "ghcr.io/ggml-org/llama.cpp:server-cuda", add_python="3.11")
29
+ .entrypoint([]) # the image defaults to exec llama-server; we manage it
30
+ .pip_install("fastapi[standard]", "huggingface_hub")
31
+ .run_commands(
32
+ "python -c \"from huggingface_hub import hf_hub_download; "
33
+ f"hf_hub_download('{MODEL_REPO}', '{MODEL_FILE}', "
34
+ "local_dir='/models')\""
35
+ )
36
+ )
37
+
38
+ app = modal.App("brad-did-something", image=image)
39
+
40
+ with image.imports():
41
+ from fastapi import HTTPException, Request
42
+
43
+
44
+ def _find_server_binary() -> str:
45
+ for path in ("/app/llama-server", "/llama-server",
46
+ "/usr/local/bin/llama-server"):
47
+ if os.path.exists(path):
48
+ return path
49
+ return "llama-server" # hope it's on PATH
50
+
51
+
52
+ @app.cls(
53
+ gpu="L4",
54
+ scaledown_window=300, # stay warm between calls within a play session
55
+ timeout=120,
56
+ secrets=[modal.Secret.from_name("bds-auth")],
57
+ )
58
+ class Llama:
59
+ @modal.enter()
60
+ def start_server(self):
61
+ self.proc = subprocess.Popen([
62
+ _find_server_binary(),
63
+ "--model", f"/models/{MODEL_FILE}",
64
+ "--ctx-size", "4096",
65
+ "--n-gpu-layers", "99",
66
+ "--port", str(LLAMA_PORT),
67
+ "--host", "127.0.0.1",
68
+ ])
69
+ deadline = time.time() + 120
70
+ while time.time() < deadline:
71
+ try:
72
+ urllib.request.urlopen(
73
+ f"http://127.0.0.1:{LLAMA_PORT}/health", timeout=2)
74
+ return
75
+ except Exception:
76
+ time.sleep(1)
77
+ raise RuntimeError("llama-server did not become healthy")
78
+
79
+ @modal.exit()
80
+ def stop_server(self):
81
+ self.proc.terminate()
82
+
83
+ @modal.fastapi_endpoint(method="POST")
84
+ def generate(self, body: dict, request: Request):
85
+ expected = os.environ.get("BDS_TOKEN", "")
86
+ sent = request.headers.get("authorization", "")
87
+ if expected and sent != f"Bearer {expected}":
88
+ raise HTTPException(401, "bad token")
89
+
90
+ t0 = time.time()
91
+ # the empty <think> block disables Qwen3.5's default thinking mode —
92
+ # the JSON grammar takes over immediately after
93
+ prompt = (
94
+ f"<|im_start|>system\n{body['system_prompt']}\n"
95
+ f"GAME STATE JSON:\n{json.dumps(body.get('context', {}))}\n<|im_end|>\n"
96
+ f"<|im_start|>user\n{body['user_prompt']}<|im_end|>\n"
97
+ f"<|im_start|>assistant\n<think>\n\n</think>\n\n"
98
+ )
99
+ payload = json.dumps({
100
+ "prompt": prompt,
101
+ "temperature": 0.4,
102
+ # 512 truncated the JSON once crises/events gained the long
103
+ # image_prompt + comic_caption fields → unterminated-string parse
104
+ # failures → fallbacks. 1024 leaves comfortable headroom.
105
+ "n_predict": 1024,
106
+ "cache_prompt": True,
107
+ "json_schema": body["schema"], # grammar-enforced at generation
108
+ }).encode()
109
+ req = urllib.request.Request(
110
+ f"http://127.0.0.1:{LLAMA_PORT}/completion",
111
+ data=payload, headers={"Content-Type": "application/json"})
112
+ try:
113
+ with urllib.request.urlopen(req, timeout=90) as resp:
114
+ out = json.loads(resp.read())
115
+ data = json.loads(out["content"])
116
+ return {"ok": True, "data": data,
117
+ "ms": int((time.time() - t0) * 1000)}
118
+ except Exception as exc: # caller falls back; never crash the endpoint
119
+ return {"ok": False, "error": str(exc)[:200],
120
+ "ms": int((time.time() - t0) * 1000)}
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=5.0
2
+ fastapi>=0.110
3
+ uvicorn>=0.29
4
+ requests>=2.31
5
+ pydantic>=2.6
run_modal.ps1 ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Launch Brad Did Something against the deployed Modal GPU endpoints.
2
+ # .\run_modal.ps1
3
+ $env:MODAL_URL = "https://qfelix0112--brad-did-something-llama-generate.modal.run"
4
+ $env:MODAL_TOKEN = (Get-Content "$PSScriptRoot\.bds_modal_token" -Raw).Trim()
5
+ # 20s tolerates the one-time container cold start; warm calls are 2-4s
6
+ $env:BDS_LLM_TIMEOUT = "20"
7
+
8
+ # --- Comic panels (FLUX image generation on Modal) — optional ---
9
+ # After `modal deploy modal_app/image.py`, Modal prints the generate_image URL.
10
+ # Paste it below (this is the predicted name — VERIFY it matches the deploy
11
+ # output). The bearer token is the SAME bds-auth BDS_TOKEN as the text model.
12
+ # Leave FLUX_URL blank to play without comics — the overlay just no-ops.
13
+ $env:FLUX_URL = "https://qfelix0112--brad-comic-flux-generate-image.modal.run"
14
+ $env:FLUX_TOKEN = $env:MODAL_TOKEN
15
+ # FLUX cold start is large (~30-60s); warm klein calls are a few seconds
16
+ $env:BDS_FLUX_TIMEOUT = "25"
17
+
18
+ python "$PSScriptRoot\app.py"
static/audio/bgm.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7e28bec5faf9a093c5704db33987cf8c6745e2cb4fe2f7ac5c20ad54bbd0308a
3
+ size 3505649
static/css/game.css ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ GAME CHROME — Brad Did Something · DAYLIGHT re-skin
3
+ Paper panels, dark-ink pixel frames, ink text. Matches the
4
+ new warmlight Design System. World pixels live on canvas +
5
+ DOM sprites inside #stage (640x480, scaled).
6
+ ============================================================ */
7
+
8
+ * { box-sizing: border-box; }
9
+ html, body {
10
+ margin: 0; padding: 0; background: var(--bg-app);
11
+ font-family: var(--font-display, "Press Start 2P", monospace);
12
+ color: var(--text-body); overflow: hidden; height: 100%;
13
+ }
14
+ .hidden { display: none !important; }
15
+ button { font-family: inherit; cursor: pointer; }
16
+
17
+ #viewport { display: flex; flex-direction: column; height: 100vh;
18
+ height: 100dvh; } /* dvh: avoids the mobile address-bar clip/scroll */
19
+
20
+ /* ---- wordmark ---- */
21
+ #wordmark {
22
+ display: flex; gap: 16px; align-items: baseline; padding: 8px 16px;
23
+ background: var(--surface-card); border-bottom: 3px solid var(--border-bright);
24
+ }
25
+ .wm-main {
26
+ font-size: 14px; color: var(--text-heading);
27
+ text-shadow: 2px 2px 0 var(--bds-amber);
28
+ }
29
+ .wm-sub { font-size: 8px; color: var(--text-muted); }
30
+
31
+ /* ---- HUD ---- */
32
+ #hud {
33
+ display: flex; gap: 16px; align-items: center; padding: 8px 16px;
34
+ background: var(--surface-card);
35
+ border-bottom: 3px solid var(--border-bright); position: relative;
36
+ }
37
+ .hud-row { display: flex; align-items: baseline; gap: 8px; }
38
+ #rev-number { font-size: 16px; color: var(--bds-success); transition: color .3s; }
39
+ #rev-number.warn { color: var(--bds-kevin); }
40
+ #rev-number.crit { color: var(--bds-brad); }
41
+ #rev-target { font-size: 8px; color: var(--text-muted); }
42
+ #rev-bar {
43
+ width: 320px; height: 12px; margin: 6px 0; background: var(--bg-well);
44
+ border: 2px solid var(--border-bright); position: relative;
45
+ }
46
+ #rev-fill {
47
+ height: 100%; width: 0%; background: var(--bds-success);
48
+ transition: width .6s steps(12);
49
+ }
50
+ #rev-fill.warn { background: var(--bds-kevin); }
51
+ #rev-fill.crit { background: var(--bds-brad); }
52
+ #boss-title { font-size: 8px; color: var(--bds-janet); white-space: nowrap;
53
+ overflow: hidden; text-overflow: ellipsis; max-width: 340px;
54
+ transition: opacity .25s; }
55
+ #hud-right { margin-left: auto; display: flex; gap: 16px; font-size: 8px;
56
+ align-items: center; }
57
+ #crisis-counter { color: var(--text-muted); }
58
+ #pocket { color: var(--bds-success); }
59
+ #hr-badge {
60
+ color: var(--bds-neon-magenta); border: 2px solid var(--bds-neon-magenta);
61
+ padding: 4px 6px; animation: hrpulse 1s steps(2) infinite;
62
+ }
63
+ @keyframes hrpulse { 50% { background: var(--bds-janet-soft); } }
64
+ #hud-banner {
65
+ position: absolute; left: 50%; top: 100%; transform: translateX(-50%);
66
+ background: var(--surface-card); border: 3px solid var(--bds-amber);
67
+ color: var(--bds-amber); font-size: 8px; padding: 8px 12px; z-index: 40;
68
+ white-space: nowrap; box-shadow: 4px 4px 0 var(--bds-void);
69
+ }
70
+ #hud-banner.gold { border-color: var(--bds-kevin); color: var(--bds-amber); }
71
+ #hud-banner.red { border-color: var(--bds-brad); color: var(--bds-white);
72
+ background: var(--bds-brad); }
73
+
74
+ /* ---- stage layout ---- */
75
+ #frame { flex: 1; display: flex; min-height: 0; }
76
+ #stage-wrap { flex: 1; display: flex; align-items: center;
77
+ justify-content: center; background: var(--bg-app); min-width: 0; }
78
+ #stage {
79
+ width: 640px; height: 480px; position: relative; flex: none;
80
+ transform-origin: center center; background: var(--bds-navy-900);
81
+ border: 4px solid var(--border-bright); image-rendering: pixelated;
82
+ box-shadow: 0 6px 0 rgba(36,31,23,.25);
83
+ overflow: hidden; /* keep wipe/flash/overlays inside the screen */
84
+ }
85
+ #floor { position: absolute; inset: 0; image-rendering: pixelated; }
86
+ #world, #fx { position: absolute; inset: 0; pointer-events: none; }
87
+ #world { z-index: 5; } #fx { z-index: 20; }
88
+
89
+ /* in-world bits */
90
+ .prompt-box {
91
+ position: absolute; transform: translateX(-50%); background: var(--bds-void);
92
+ border: 2px solid var(--bds-white); color: var(--bds-white); font-size: 6px;
93
+ padding: 3px 5px; z-index: 30; white-space: nowrap; letter-spacing: 1px;
94
+ }
95
+ .prompt-box.gold { border-color: var(--bds-kevin); color: var(--bds-kevin);
96
+ box-shadow: 0 0 8px rgba(207,154,42,.7); }
97
+ .bubble {
98
+ position: absolute; transform: translateX(-50%); z-index: 28;
99
+ width: 18px; height: 18px; background: var(--bds-white);
100
+ border: 2px solid var(--bds-brad); color: var(--bds-brad);
101
+ font-size: 9px; line-height: 14px; text-align: center;
102
+ box-shadow: 2px 2px 0 var(--bds-void);
103
+ animation: bpop .35s var(--ease-pop, ease-out), bfloat 1s steps(2) infinite 0.4s;
104
+ }
105
+ .bubble.amber { border-color: var(--bds-amber); color: var(--bds-amber); }
106
+ .bubble.heart { border-color: var(--bds-neon-magenta); color: var(--bds-neon-magenta); }
107
+ @keyframes bpop { 0% { transform: translateX(-50%) scale(0); }
108
+ 70% { transform: translateX(-50%) scale(1.3); }
109
+ 100% { transform: translateX(-50%) scale(1); } }
110
+ @keyframes bfloat { 50% { margin-top: -3px; } }
111
+
112
+ .say-bubble {
113
+ position: absolute; transform: translateX(-50%); z-index: 32;
114
+ max-width: 150px; background: var(--bds-white);
115
+ border: 2px solid var(--border-bright); color: var(--text-body);
116
+ font-size: 6px; line-height: 1.9; padding: 5px 7px; text-align: left;
117
+ box-shadow: 3px 3px 0 var(--bds-void);
118
+ animation: saypop .25s var(--ease-pop, ease-out);
119
+ }
120
+ .say-bubble::after {
121
+ content: ""; position: absolute; left: 50%; bottom: -6px;
122
+ margin-left: -3px; border: 3px solid transparent;
123
+ border-top-color: var(--border-bright);
124
+ }
125
+ .say-bubble.fading { opacity: 0; transition: opacity .5s steps(4); }
126
+ @keyframes saypop { 0% { transform: translateX(-50%) scale(.4); } }
127
+
128
+ .newspaper {
129
+ position: absolute; width: 34px; height: 24px; background: #efe9d8;
130
+ border: 2px solid var(--bds-void); z-index: 12; font-size: 4px;
131
+ color: #3a3326; padding: 2px; overflow: hidden; line-height: 1.4;
132
+ }
133
+ .newspaper.falling { animation: npfall 1.2s steps(8) forwards; }
134
+ @keyframes npfall {
135
+ 0% { transform: translateY(-300px) rotate(0); }
136
+ 100% { transform: translateY(0) rotate(720deg); } }
137
+ .newspaper.floor { opacity: .45; }
138
+ .envelope {
139
+ position: absolute; width: 22px; height: 14px; background: #f2ecda;
140
+ border: 2px solid var(--bds-void); z-index: 12;
141
+ box-shadow: 0 0 8px rgba(63,154,63,.8);
142
+ animation: envslide .8s steps(8); }
143
+ .envelope::after { content:""; position:absolute; left:0; top:0;
144
+ border-left:9px solid transparent; border-right:9px solid transparent;
145
+ border-top:7px solid #d8d0b8; }
146
+ @keyframes envslide { 0% { transform: translateX(300px); } }
147
+
148
+ .float-text {
149
+ position: absolute; font-size: 10px; z-index: 60; pointer-events: none;
150
+ text-shadow: 2px 2px 0 var(--bds-void); white-space: nowrap;
151
+ animation: floatup 1.6s steps(8) forwards;
152
+ }
153
+ .float-text.down { animation: floatdown 1.6s steps(8) forwards; }
154
+ @keyframes floatup { 0% { opacity:1; } 100% { transform: translateY(-46px); opacity:0; } }
155
+ @keyframes floatdown { 0% { opacity:1; } 100% { transform: translateY(46px); opacity:0; } }
156
+
157
+ .pixel-part { position: absolute; z-index: 55; pointer-events: none; }
158
+
159
+ /* ---- overlays inside stage ---- */
160
+ #dim { position: absolute; inset: 0; background: rgba(36,31,23,.45); z-index: 35;
161
+ transition: opacity .25s; }
162
+ #flash { position: absolute; inset: 0; background: var(--bds-brad); opacity: 0;
163
+ z-index: 70; pointer-events: none; }
164
+ #flash.on { animation: redflash .5s steps(4) forwards; }
165
+ @keyframes redflash { 0% { opacity: .3; } 100% { opacity: 0; } }
166
+ #edge-pulse { position: absolute; inset: 0; z-index: 68; pointer-events: none;
167
+ opacity: 0; box-shadow: inset 0 0 36px 12px rgba(210,89,58,.5); }
168
+ #edge-pulse.on { animation: edgep 1s steps(4) infinite; }
169
+ @keyframes edgep { 50% { opacity: 1; } }
170
+ #wipe { position: absolute; inset: 0; background: var(--bds-void); z-index: 90;
171
+ transform: translateX(-100%); pointer-events: none; }
172
+ #wipe.go { animation: wipeacross .7s steps(10) forwards; }
173
+ @keyframes wipeacross { 0% { transform: translateX(-100%); }
174
+ 45%,55% { transform: translateX(0); } 100% { transform: translateX(100%); } }
175
+ #stamp {
176
+ position: absolute; left: 50%; top: 40%; transform: translate(-50%,-50%) rotate(-8deg);
177
+ font-size: 32px; color: var(--bds-white); z-index: 80;
178
+ background: var(--bds-neon-magenta);
179
+ border: 6px solid var(--bds-void); padding: 12px 20px;
180
+ box-shadow: 6px 6px 0 var(--bds-void);
181
+ animation: stampin .9s steps(3) forwards;
182
+ }
183
+ @keyframes stampin { 0% { transform: translate(-50%,-50%) scale(3) rotate(-8deg);
184
+ opacity: 0; } 30% { opacity: 1; } 80% { opacity: 1; }
185
+ 100% { transform: translate(-50%,-50%) scale(1) rotate(-8deg); opacity: 0; } }
186
+
187
+ /* ---- paper trail ---- */
188
+ #papertrail {
189
+ width: 230px; padding: 8px; background: var(--surface-card);
190
+ border-left: 3px solid var(--border-bright);
191
+ display: flex; flex-direction: column; min-height: 0;
192
+ }
193
+ .pt-head { font-size: 8px; color: var(--text-muted); letter-spacing: 2px;
194
+ padding-bottom: 8px; border-bottom: 2px solid var(--border); }
195
+ #pt-entries { overflow-y: auto; flex: 1; scrollbar-width: thin; }
196
+ .pt-empty { font-size: 7px; color: var(--text-disabled); padding: 8px 0; }
197
+ .pt-entry { font-size: 7px; line-height: 1.9; padding: 6px 0;
198
+ border-bottom: 1px solid var(--border);
199
+ animation: ptslide .3s steps(4); }
200
+ @keyframes ptslide { 0% { transform: translateX(40px); opacity: 0; } }
201
+ .pt-entry .pt-npc { letter-spacing: 1px; }
202
+ .pt-entry .pt-delta.up { color: var(--bds-success); }
203
+ .pt-entry .pt-delta.down { color: var(--bds-brad); }
204
+ .pt-entry .pt-text { color: var(--text-muted); }
205
+
206
+ /* ---- dialogue box ---- */
207
+ #dialogue {
208
+ position: fixed; left: 50%; bottom: 0; transform: translateX(-50%);
209
+ width: min(720px, 96vw); max-height: 62vh; overflow-y: auto;
210
+ background: var(--surface-card);
211
+ border: 4px solid var(--npc-color, var(--border-bright));
212
+ box-shadow: 8px 8px 0 var(--bds-void);
213
+ z-index: 200; padding: 14px;
214
+ animation: dlgup .3s steps(6);
215
+ }
216
+ @keyframes dlgup { 0% { transform: translate(-50%, 105%); } }
217
+ #dialogue.closing { animation: dlgdown .25s steps(5) forwards; }
218
+ @keyframes dlgdown { 100% { transform: translate(-50%, 105%); } }
219
+ #dialogue.gold { border-color: var(--bds-kevin); }
220
+ #dialogue.tone-warm { border-color: var(--bds-success); }
221
+ #dialogue.tone-neutral { border-color: var(--bds-ink-3); }
222
+ #dialogue.tone-concerned { border-color: var(--bds-kevin); }
223
+ #dialogue.tone-alarmed { border-color: var(--bds-brad); }
224
+
225
+ /* ---- comic-panel crisis overlay (FLUX-generated; no overlay when no image) ---- */
226
+ #comic {
227
+ position: fixed; inset: 0; z-index: 300;
228
+ display: flex; align-items: center; justify-content: center;
229
+ background: rgba(36, 31, 23, .72);
230
+ cursor: pointer; opacity: 0; transition: opacity .18s ease;
231
+ }
232
+ #comic.comic-in { opacity: 1; }
233
+ #comic.hidden { display: none; }
234
+ .comic-frame {
235
+ position: relative;
236
+ display: flex; flex-direction: column; gap: 8px;
237
+ max-width: min(880px, 94vw); max-height: 90vh;
238
+ background: var(--bds-white); padding: 10px;
239
+ border: 6px solid var(--bds-void);
240
+ box-shadow: 10px 10px 0 var(--bds-void);
241
+ transform: scale(.95) rotate(-.5deg);
242
+ transition: transform .22s cubic-bezier(.2, 1.3, .5, 1);
243
+ }
244
+ #comic.comic-in .comic-frame { transform: scale(1) rotate(0); }
245
+ .comic-caption {
246
+ font-size: 11px; line-height: 1.7; color: var(--bds-void);
247
+ background: var(--bds-amber); border: 3px solid var(--bds-void);
248
+ padding: 8px 10px; text-align: center; letter-spacing: .5px;
249
+ }
250
+ .comic-img {
251
+ display: block; max-width: 100%; max-height: 74vh;
252
+ border: 2px solid var(--bds-void);
253
+ }
254
+ .comic-hint {
255
+ position: absolute; right: 12px; bottom: 12px;
256
+ font-size: 8px; letter-spacing: 1px; color: var(--bds-white);
257
+ background: var(--bds-void); padding: 4px 7px; opacity: .85;
258
+ }
259
+
260
+ .dlg-head { display: flex; gap: 10px; align-items: center; padding-bottom: 10px;
261
+ border-bottom: 2px solid var(--border); margin-bottom: 10px; }
262
+ .dlg-name { font-size: 11px; color: var(--npc-color, var(--text-heading)); }
263
+ .dlg-title { font-size: 7px; color: var(--text-muted); display: block;
264
+ margin-top: 5px; }
265
+ .dlg-sprite { width: 72px; height: 66px; position: relative; flex: none; }
266
+ .dlg-headline { font-size: 8px; color: var(--bds-amber); margin-bottom: 8px;
267
+ letter-spacing: 1px; }
268
+ .dlg-body { font-size: 9px; line-height: 2; color: var(--text-body);
269
+ margin-bottom: 12px; }
270
+ .dlg-urgency { font-size: 8px; color: var(--bds-neon-magenta); margin: 8px 0;
271
+ text-align: center; }
272
+
273
+ .dlg-options { display: flex; gap: 10px; margin-bottom: 10px; }
274
+ .dlg-option {
275
+ flex: 1; background: var(--surface-hover); padding: 10px; text-align: left;
276
+ border: 2px solid var(--bds-brad); color: var(--text-body);
277
+ font-size: 8px; line-height: 1.9; box-shadow: 3px 3px 0 var(--bds-void);
278
+ }
279
+ .dlg-option.b { border-color: var(--bds-kevin); }
280
+ .dlg-option .opt-label { display: block; color: var(--bds-brad); font-size: 7px;
281
+ letter-spacing: 1px; margin-bottom: 6px; }
282
+ .dlg-option.b .opt-label { color: var(--bds-amber); }
283
+ .dlg-option:hover { background: var(--bds-white); }
284
+ .dlg-option:active { transform: translate(3px,3px); box-shadow: none; }
285
+
286
+ .dlg-quick { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
287
+ .btn-quick {
288
+ background: var(--surface-hover); border: 2px solid var(--border-bright);
289
+ color: var(--text-heading); font-size: 7px; padding: 8px 10px;
290
+ box-shadow: 3px 3px 0 var(--bds-void);
291
+ }
292
+ .btn-quick:active { transform: translate(3px,3px); box-shadow: none; }
293
+ .btn-quick.danger { border-color: var(--bds-brad); color: var(--bds-brad); }
294
+
295
+ .dlg-inputrow { display: flex; gap: 8px; }
296
+ .dlg-input {
297
+ flex: 1; background: var(--bds-white); border: 2px solid var(--border-bright);
298
+ color: var(--text-body); font-family: inherit; font-size: 8px;
299
+ padding: 10px; outline: none;
300
+ }
301
+ .dlg-input::placeholder { color: var(--text-disabled); }
302
+ .dlg-input:focus { border-color: var(--bds-neon-cyan);
303
+ box-shadow: 0 0 8px rgba(31,127,174,.4); }
304
+ .btn-send {
305
+ background: var(--bds-neon-magenta); border: 2px solid var(--bds-void);
306
+ color: var(--bds-white); font-size: 8px; padding: 10px 14px;
307
+ box-shadow: 3px 3px 0 var(--bds-void);
308
+ }
309
+ .btn-send:active { transform: translate(3px,3px); box-shadow: none; }
310
+
311
+ .dlg-thinking { font-size: 9px; color: var(--text-muted); padding: 18px 0;
312
+ text-align: center; }
313
+ .dlg-thinking .dots::after { content: ""; animation: dots 1.2s steps(4) infinite; }
314
+ @keyframes dots { 0% { content: ""; } 25% { content: "."; }
315
+ 50% { content: ".."; } 75% { content: "..."; } }
316
+
317
+ .dlg-after-you { border: 2px solid var(--border); padding: 8px;
318
+ font-size: 7px; color: var(--text-muted); margin-bottom: 8px;
319
+ background: var(--surface-hover); }
320
+ .dlg-after-react { border-left: 4px solid var(--npc-color, var(--border-bright));
321
+ padding: 8px; font-size: 9px; line-height: 2; margin-bottom: 8px;
322
+ color: var(--text-body); }
323
+ .dlg-after-conseq { border-left: 4px solid var(--bds-success); padding: 8px;
324
+ font-size: 8px; line-height: 1.9; color: var(--text-body);
325
+ margin-bottom: 10px; }
326
+ .dlg-after-conseq.down { border-left-color: var(--bds-brad); }
327
+ .btn-next {
328
+ width: 100%; background: var(--bds-neon-magenta); border: 2px solid var(--bds-void);
329
+ color: var(--bds-white); font-size: 10px; padding: 14px;
330
+ box-shadow: 4px 4px 0 var(--bds-void);
331
+ }
332
+ .btn-next:active { transform: translate(4px,4px); box-shadow: none; }
333
+
334
+ .round-dots { font-size: 10px; color: var(--bds-amber); letter-spacing: 4px; }
335
+ .board-badge { font-size: 7px; color: var(--bds-brad);
336
+ border: 2px solid var(--bds-brad); padding: 4px 6px; margin-left: auto; }
337
+ .wrong-slide {
338
+ background: var(--bds-janet-soft); border: 4px solid var(--bds-neon-magenta);
339
+ padding: 12px; text-align: center; margin-bottom: 10px; color: #5a3a50;
340
+ font-size: 8px;
341
+ }
342
+ .wrong-slide .ws-photo { width: 90px; height: 64px; background: #d8d0e2;
343
+ margin: 6px auto; border: 2px solid #9a90b0; position: relative;
344
+ display: flex; align-items: flex-end; justify-content: center;
345
+ overflow: visible; }
346
+ .wrong-slide .ws-title { font-size: 10px; color: var(--bds-neon-magenta);
347
+ font-style: italic; }
348
+
349
+ /* ---- gift panel ---- */
350
+ #gift-panel {
351
+ position: fixed; left: 50%; top: 50%; transform: translate(-50%,-50%);
352
+ background: var(--surface-card);
353
+ border: 4px solid var(--npc-color, var(--border-bright));
354
+ box-shadow: 8px 8px 0 var(--bds-void); z-index: 210; padding: 14px;
355
+ width: 320px;
356
+ }
357
+ .gift-head { font-size: 9px; color: var(--npc-color, var(--text-heading));
358
+ margin-bottom: 10px; }
359
+ .gift-balance { font-size: 7px; color: var(--bds-success); margin-bottom: 10px; }
360
+ .gift-row {
361
+ display: flex; justify-content: space-between; width: 100%;
362
+ background: var(--surface-hover); border: 2px solid var(--border-bright);
363
+ color: var(--text-body); font-size: 8px; padding: 10px; margin-bottom: 8px;
364
+ box-shadow: 2px 2px 0 var(--bds-void);
365
+ }
366
+ .gift-row:hover:not(:disabled) { border-color: var(--bds-success);
367
+ background: var(--bds-white); }
368
+ .gift-row:disabled { color: var(--text-disabled); border-color: var(--border);
369
+ box-shadow: none; cursor: not-allowed; }
370
+ .gift-cancel { font-size: 7px; color: var(--text-muted); background: none;
371
+ border: none; width: 100%; padding: 6px; }
372
+
373
+ /* ---- boardroom ---- */
374
+ #boardroom { position: absolute; inset: 0; z-index: 15;
375
+ background: var(--bds-purple-900); }
376
+
377
+ /* ---- title screen ---- */
378
+ #title-screen {
379
+ position: fixed; inset: 0; z-index: 300;
380
+ display: flex; align-items: center; justify-content: center;
381
+ /* soft vignette focuses the card and lifts it off the flat field */
382
+ background: radial-gradient(ellipse at center,
383
+ var(--bds-purple-800) 0%, var(--bds-navy-800) 100%);
384
+ }
385
+ .ts-card {
386
+ text-align: center; max-width: 560px; padding: 32px 44px;
387
+ background: var(--surface-card); border: 4px solid var(--border-bright);
388
+ box-shadow: 10px 10px 0 var(--bds-void);
389
+ }
390
+ .ts-logo {
391
+ font-size: 38px; color: var(--text-heading); line-height: 1.4;
392
+ text-shadow: 4px 4px 0 var(--bds-amber);
393
+ animation: ts-logo 2.8s ease-in-out infinite;
394
+ }
395
+ @keyframes ts-logo {
396
+ 0%, 100% { transform: translateY(0);
397
+ text-shadow: 4px 4px 0 var(--bds-amber); }
398
+ 50% { transform: translateY(-3px);
399
+ text-shadow: 4px 4px 0 var(--bds-amber), 0 0 16px rgba(185,133,42,.55); }
400
+ }
401
+ .ts-sub { display: block; font-size: 9px; color: var(--bds-neon-cyan);
402
+ margin: 14px 0 18px; letter-spacing: 3px;
403
+ text-shadow: 1px 1px 0 rgba(36,31,23,.25); }
404
+ .ts-premise { font-size: 8px; line-height: 2.2; color: var(--text-body);
405
+ margin-bottom: 22px; }
406
+ .btn-cta {
407
+ background: var(--bds-neon-magenta); color: var(--bds-white);
408
+ border: 3px solid var(--bds-void);
409
+ font-size: 12px; padding: 16px 28px; box-shadow: 5px 5px 0 var(--bds-void);
410
+ }
411
+ .btn-cta:active { transform: translate(5px,5px); box-shadow: none; }
412
+ #btn-start { animation: ts-cta 1.9s ease-in-out infinite; }
413
+ @keyframes ts-cta {
414
+ 0%, 100% { box-shadow: 5px 5px 0 var(--bds-void); }
415
+ 50% { box-shadow: 5px 5px 0 var(--bds-void), 0 0 18px rgba(192,57,143,.6); }
416
+ }
417
+ .ts-keys { margin-top: 18px; font-size: 7px; color: var(--text-body);
418
+ line-height: 1.8; }
419
+ #ts-cast { display: flex; justify-content: center; gap: 14px;
420
+ margin-bottom: 22px; align-items: flex-end; }
421
+ /* the team idles in place, gently, staggered so it reads as a busy office */
422
+ #ts-cast .cast-slot { text-align: center; animation: ts-bob 2.4s ease-in-out infinite; }
423
+ #ts-cast .cast-slot:nth-child(2) { animation-delay: .35s; }
424
+ #ts-cast .cast-slot:nth-child(3) { animation-delay: .7s; }
425
+ #ts-cast .cast-slot:nth-child(4) { animation-delay: 1.05s; }
426
+ #ts-cast .cast-slot:nth-child(5) { animation-delay: 1.4s; }
427
+ @keyframes ts-bob { 0%, 100% { transform: translateY(0); }
428
+ 50% { transform: translateY(-5px); } }
429
+ /* ink shadow keeps even the muted gold/blue names legible on cream */
430
+ #ts-cast .cast-name { font-size: 6px; margin-top: 6px; letter-spacing: 1px;
431
+ text-shadow: 1px 1px 0 var(--bds-void); }
432
+
433
+ /* ---- review screen ---- */
434
+ #review-screen {
435
+ position: fixed; inset: 0; background: var(--bg-app); z-index: 290;
436
+ display: flex; align-items: center; justify-content: center;
437
+ }
438
+ .rv-card {
439
+ width: min(680px, 94vw); max-height: 92vh; overflow-y: auto;
440
+ border: 6px solid var(--rv-color, var(--border-bright)); padding: 22px;
441
+ background: var(--surface-card);
442
+ box-shadow: 8px 8px 0 var(--bds-void);
443
+ }
444
+ .rv-head { font-size: 14px; color: var(--text-heading); margin-bottom: 16px;
445
+ letter-spacing: 2px; }
446
+ .rv-revenue { font-size: 28px; color: var(--rv-color, var(--text-heading));
447
+ margin-bottom: 6px; }
448
+ .rv-gap { font-size: 8px; color: var(--text-muted); margin-bottom: 14px; }
449
+ .rv-line { font-size: 8px; line-height: 2.2; color: var(--text-body); }
450
+ .rv-title { color: var(--bds-janet); }
451
+ .rv-section { margin: 14px 0; border-top: 2px solid var(--border);
452
+ padding-top: 12px; }
453
+ .rv-sec-head { font-size: 8px; color: var(--text-muted); letter-spacing: 2px;
454
+ margin-bottom: 8px; }
455
+ .rv-verdict { font-size: 9px; line-height: 2.1; color: var(--bds-amber); }
456
+
457
+ /* ---- ambient light wash (replaces CRT — daylight DS, no scanlines) ---- */
458
+ #crt {
459
+ position: fixed; inset: 0; pointer-events: none; z-index: 999;
460
+ background: radial-gradient(ellipse at 50% 0%,
461
+ rgba(255,248,226,.10) 0%, transparent 55%);
462
+ }
463
+
464
+ /* ---- reduced motion + mobile ---- */
465
+ @media (prefers-reduced-motion: reduce) {
466
+ *, *::before, *::after { animation-duration: .01s !important;
467
+ transition-duration: .01s !important; }
468
+ }
469
+ @media (max-width: 760px) {
470
+ #papertrail { display: none; }
471
+ #rev-bar { width: 140px; }
472
+ #hud { flex-wrap: wrap; gap: 8px 12px; padding: 6px 10px; }
473
+ #hud-right { gap: 10px; }
474
+ #wordmark { padding: 6px 10px; gap: 10px; }
475
+ .wm-main { font-size: 11px; }
476
+ .dlg-options { flex-direction: column; }
477
+ #dialogue { width: 96vw; max-height: 56vh; padding: 11px; }
478
+ .ts-card, .rv-card { max-width: 94vw; max-height: 88vh; overflow-y: auto; }
479
+ .comic-caption { font-size: 9px; }
480
+ }
481
+
482
+ /* ---- touch controls (created by touch.js only on touch devices) ---- */
483
+ #tc-stick, #tc-buttons {
484
+ position: fixed; bottom: 20px; z-index: 150;
485
+ touch-action: none; user-select: none; -webkit-user-select: none;
486
+ }
487
+ #tc-stick {
488
+ left: 20px; width: 122px; height: 122px; border-radius: 50%;
489
+ background: rgba(36, 31, 23, .20); border: 3px solid rgba(36, 31, 23, .45);
490
+ display: flex; align-items: center; justify-content: center;
491
+ }
492
+ .tc-thumb {
493
+ width: 52px; height: 52px; border-radius: 50%;
494
+ background: var(--bds-amber); border: 3px solid var(--bds-void);
495
+ box-shadow: 0 3px 0 rgba(36, 31, 23, .4);
496
+ }
497
+ #tc-buttons { right: 20px; display: flex; gap: 14px; align-items: center; }
498
+ .tc-btn {
499
+ font-family: var(--font-display, "Press Start 2P", monospace);
500
+ border: 3px solid var(--bds-void); color: var(--bds-void); padding: 0;
501
+ box-shadow: 0 4px 0 rgba(36, 31, 23, .4);
502
+ }
503
+ .tc-btn.down { transform: translateY(3px); box-shadow: 0 1px 0 rgba(36, 31, 23, .4); }
504
+ .tc-action {
505
+ width: 88px; height: 88px; border-radius: 50%; font-size: 13px;
506
+ background: var(--bds-success, #3fae62); color: var(--bds-white, #fff);
507
+ }
508
+ .tc-gift {
509
+ width: 62px; height: 62px; border-radius: 50%; font-size: 9px;
510
+ background: var(--bds-amber);
511
+ }
512
+ /* hide the controls whenever a menu / dialogue / comic owns the screen */
513
+ :is(#title-screen, #review-screen, #dialogue, #comic, #gift-panel):not(.hidden) ~ #tc-stick,
514
+ :is(#title-screen, #review-screen, #dialogue, #comic, #gift-panel):not(.hidden) ~ #tc-buttons {
515
+ display: none;
516
+ }
static/css/tokens.css ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ BASE — Brad Did Something
3
+ Element defaults so any consumer page reads as the game by
4
+ default: warm paper canvas, dark-ink headings, warm body text,
5
+ clean (no scanlines).
6
+ ============================================================ */
7
+
8
+ *, *::before, *::after { box-sizing: border-box; }
9
+
10
+ html { -webkit-text-size-adjust: 100%; }
11
+
12
+ body {
13
+ margin: 0;
14
+ background: var(--bg-app);
15
+ color: var(--text-body);
16
+ font-family: var(--font-body);
17
+ font-size: var(--fs-body);
18
+ line-height: var(--lh-body);
19
+ letter-spacing: var(--ls-normal);
20
+ -webkit-font-smoothing: none; /* keep pixels crunchy */
21
+ font-smooth: never;
22
+ text-rendering: optimizeSpeed;
23
+ }
24
+
25
+ h1, h2, h3, h4, h5, h6 {
26
+ font-family: var(--font-display);
27
+ color: var(--text-heading);
28
+ line-height: var(--lh-tight);
29
+ margin: 0 0 var(--space-4);
30
+ font-weight: 400;
31
+ }
32
+ h1 { font-size: var(--fs-h1); }
33
+ h2 { font-size: var(--fs-h2); }
34
+ h3 { font-size: var(--fs-h3); }
35
+
36
+ p { margin: 0 0 var(--space-4); text-wrap: pretty; }
37
+
38
+ a {
39
+ color: var(--link);
40
+ text-decoration: none;
41
+ border-bottom: 2px solid currentColor;
42
+ }
43
+ a:hover { text-shadow: var(--text-glow-cyan); }
44
+
45
+ :focus-visible {
46
+ outline: none;
47
+ box-shadow: var(--focus-shadow);
48
+ }
49
+
50
+ code, kbd, samp { font-family: var(--font-mono); }
51
+
52
+ img[data-pixel-img], .pixel-art { image-rendering: pixelated; }
53
+
54
+ /* ---------- Utility: arcade marquee text ---------- */
55
+ .bds-marquee {
56
+ color: var(--text-heading);
57
+ text-shadow: var(--text-glow-magenta);
58
+ }
59
+ .bds-terminal { color: var(--text-body); }
60
+ .bds-money { color: var(--text-money); text-shadow: var(--text-glow-lime); }
61
+
62
+ /* ---------- Utility: full-screen daylight shell ----------
63
+ Wrap a page in <div class="bds-screen"> to get the warm paper
64
+ canvas + soft top-light automatically. (CRT is retired.) */
65
+ .bds-screen {
66
+ position: relative;
67
+ min-height: 100vh;
68
+ background:
69
+ radial-gradient(ellipse at 50% -10%, var(--bds-purple-900) 0%, var(--bg-app) 60%);
70
+ overflow: clip;
71
+ }
72
+ /* ============================================================
73
+ COLORS — Brad Did Something · DAYLIGHT 3/4 re-skin
74
+ Warm, sunlit office: tiled warm-grey floors, cream paper
75
+ panels, dark warm INK for outlines + text, the five
76
+ character colors muted to read in daylight. No CRT, no neon
77
+ void. (--bds-void stays dark = it is the INK; --bds-white
78
+ stays light = highlights / player / light fills.)
79
+ ============================================================ */
80
+
81
+ :root {
82
+ /* ---- INK (dark, warm) — outlines, borders, hard edges, text ---- */
83
+ --bds-void: #241f17; /* the ink line behind everything */
84
+ --bds-ink: #2b2519; /* near-ink */
85
+ --bds-ink-2: #4b4534; /* body text */
86
+ --bds-ink-3: #7c755f; /* muted label */
87
+ --bds-ink-4: #a79f88; /* disabled / ghost */
88
+
89
+ /* ---- PAPER ramp (warm light) — was the navy/purple ramp.
90
+ darkest = desktop behind panels · lightest = hover ---- */
91
+ --bds-navy-900: #b7b1a0; /* app background (warm grey carpet) */
92
+ --bds-navy-800: #a59e8b; /* recessed wells */
93
+ --bds-purple-900: #ccc6b5; /* alt background band */
94
+ --bds-purple-800: #d7d1c0; /* raised surface */
95
+ --bds-purple-700: #e9e4d3; /* card surface (cream) */
96
+ --bds-purple-600: #f4f0e2; /* hover surface */
97
+
98
+ /* ---- Light ink: highlights, player, light fills ---- */
99
+ --bds-white: #faf7ee; /* warm white */
100
+ --bds-phosphor: #3f5a44; /* terminal/log text (dark sage, reads on paper) */
101
+ --bds-phosphor-dim:#6a7d62; /* muted terminal text */
102
+ --bds-amber: #b9852a; /* warning / scrutiny gold (reads on paper) */
103
+ --bds-grey: #7c755f; /* low-emphasis label */
104
+ --bds-grey-dim: #a79f88; /* disabled / ghost ink */
105
+
106
+ /* ---- The five underlings — muted for daylight ---- */
107
+ --bds-brad: #d2593a; /* warm orange-red — the problem */
108
+ --bds-stacey: #1f9c8e; /* teal — the competent one */
109
+ --bds-kevin: #cf9a2a; /* gold — the pie chart guy */
110
+ --bds-janet: #9a52c4; /* purple — the wildcard */
111
+ --bds-derek: #4f93c4; /* blue — barely present */
112
+
113
+ /* character tints — light wash behind portraits */
114
+ --bds-brad-soft: #f0d8cd;
115
+ --bds-stacey-soft: #d2e9e4;
116
+ --bds-kevin-soft: #efe4c4;
117
+ --bds-janet-soft: #e7dcf0;
118
+ --bds-derek-soft: #d6e6f2;
119
+
120
+ /* ---- System accents — daylight-legible ---- */
121
+ --bds-neon-magenta:#c0398f; /* primary accent / CTA (raspberry) */
122
+ --bds-neon-cyan: #1f7fae; /* links, focus, selection (sea blue) */
123
+ --bds-neon-lime: #3f9a3f; /* success / money (forest green) */
124
+
125
+ /* ---- Semantic status ---- */
126
+ --bds-success: #3f9a3f; /* revenue went up (rare) */
127
+ --bds-warning: #cf9a2a; /* a Brad is forming */
128
+ --bds-danger: #d2593a; /* a Brad has formed */
129
+ --bds-info: #4f93c4;
130
+
131
+ /* ============================================================
132
+ SEMANTIC ALIASES — design against these, not the raw scale
133
+ ============================================================ */
134
+ --bg-app: var(--bds-navy-900);
135
+ --bg-band: var(--bds-purple-900);
136
+ --bg-well: var(--bds-navy-800);
137
+ --surface-raised: var(--bds-purple-800);
138
+ --surface-card: var(--bds-purple-700);
139
+ --surface-hover: var(--bds-purple-600);
140
+
141
+ --text-heading: var(--bds-ink);
142
+ --text-body: var(--bds-ink-2);
143
+ --text-muted: var(--bds-ink-3);
144
+ --text-disabled: var(--bds-ink-4);
145
+ --text-money: var(--bds-success);
146
+
147
+ --accent: var(--bds-neon-magenta);
148
+ --accent-2: var(--bds-neon-cyan);
149
+ --link: var(--bds-neon-cyan);
150
+ --focus-ring: var(--bds-neon-cyan);
151
+ --selection: var(--bds-neon-magenta);
152
+
153
+ --border: #b3ab95; /* soft divider / inset edge */
154
+ --border-bright: #3a3326; /* the dark pixel frame */
155
+ --border-neon: var(--bds-neon-magenta);
156
+ }
157
+
158
+ ::selection {
159
+ background: var(--selection);
160
+ color: var(--bds-white);
161
+ }
162
+ /* ============================================================
163
+ FONTS — Brad Did Something
164
+ One typeface to rule them all: Press Start 2P.
165
+ A bitmap arcade font (Namco, 1980s). Looks best at multiples
166
+ of 8px. We ship the latin woff2 locally so consumers are
167
+ offline-safe.
168
+ ============================================================ */
169
+
170
+ @font-face {
171
+ font-family: "Press Start 2P";
172
+ font-style: normal;
173
+ font-weight: 400;
174
+ font-display: swap;
175
+ src: url("../fonts/PressStart2P-Regular.woff2") format("woff2");
176
+ }
177
+ /* ============================================================
178
+ TYPOGRAPHY — Brad Did Something
179
+ Everything is Press Start 2P. Headlines = arcade marquee.
180
+ Body = crashed terminal. Sizes are multiples of 8 because
181
+ the font is a bitmap and renders crisp on the grid.
182
+ ============================================================ */
183
+
184
+ :root {
185
+ --font-pixel: "Press Start 2P", "Courier New", monospace;
186
+ --font-display: var(--font-pixel);
187
+ --font-body: var(--font-pixel);
188
+ --font-mono: var(--font-pixel);
189
+
190
+ /* ---- Type scale (px, multiples of 8 where possible) ----
191
+ Press Start 2P runs LARGE and WIDE per glyph, so the body
192
+ sizes are small numerically but read big. Never go below
193
+ 8px; UI body lives at 10–12px, prose at 12px. */
194
+ --fs-display: 40px; /* hero / title screen */
195
+ --fs-h1: 28px; /* screen titles, arcade marquee */
196
+ --fs-h2: 20px; /* section headers */
197
+ --fs-h3: 16px; /* sub-headers */
198
+ --fs-body: 12px; /* default body / dialogue */
199
+ --fs-ui: 10px; /* buttons, labels, HUD readouts */
200
+ --fs-fine: 8px; /* legal-ish memo footer, terminal logs */
201
+
202
+ /* ---- Line height ----
203
+ Pixel fonts need air. 1.8–2.0 keeps the scanline rhythm. */
204
+ /* line-height — pixel fonts need air; 1.8–2.0 keeps scanline rhythm */
205
+ --lh-tight: 1.4; /* @kind other */
206
+ --lh-body: 1.9; /* @kind other */
207
+ --lh-loose: 2.2; /* @kind other */
208
+
209
+ /* ---- Letter spacing ----
210
+ The glyphs are already monospaced & square; only nudge. */
211
+ --ls-tight: -0.5px;
212
+ --ls-normal: 0px;
213
+ --ls-wide: 2px; /* HUD labels, all-caps tags */
214
+
215
+ /* ---- Semantic roles ---- */
216
+ --text-display-size: var(--fs-display);
217
+ --text-h1-size: var(--fs-h1);
218
+ --text-body-size: var(--fs-body);
219
+ --text-ui-size: var(--fs-ui);
220
+ }
221
+
222
+ /* Pixel fonts must NOT be anti-aliased into mush. Keep edges
223
+ crisp; let the CRT overlay do the softening. */
224
+ :root {
225
+ --pixel-render: pixelated; /* @kind other */
226
+ }
227
+ /* ============================================================
228
+ SPACING — Brad Did Something
229
+ 8px grid. The font lives on it; so does everything else.
230
+ No fractional spacing. Pixels are sacred.
231
+ ============================================================ */
232
+
233
+ :root {
234
+ --space-0: 0px;
235
+ --space-1: 4px; /* hairline gap (half-step, use sparingly) */
236
+ --space-2: 8px; /* base unit */
237
+ --space-3: 12px;
238
+ --space-4: 16px;
239
+ --space-5: 24px;
240
+ --space-6: 32px;
241
+ --space-7: 48px;
242
+ --space-8: 64px;
243
+ --space-9: 96px;
244
+
245
+ /* ---- Radius ----
246
+ Pixel UI = mostly hard corners. We allow a single 4px
247
+ "chunky pixel" rounding for soft chips; never smooth. */
248
+ --radius-0: 0px; /* default — everything is square */
249
+ --radius-chunk:4px; /* chips, avatars, soft cards */
250
+ --radius-pill: 0px; /* there are no pills here */
251
+
252
+ /* ---- Border widths (the pixel frame) ---- */
253
+ --bw-1: 2px; /* default UI border */
254
+ --bw-2: 4px; /* emphasized frame / dialogue boxes */
255
+ --bw-3: 6px; /* HUD chrome, window bezels */
256
+
257
+ /* ---- Containers ---- */
258
+ --container-screen: 1280px;
259
+ --container-narrow: 720px;
260
+ --hud-bar-height: 64px;
261
+ }
262
+ /* ============================================================
263
+ EFFECTS — Brad Did Something
264
+ CRT scanlines, neon glow, hard pixel shadows, glitch.
265
+ Shadows are HARD-OFFSET (no blur) to read as 8-bit. Glow
266
+ is the ONLY place blur is allowed.
267
+ ============================================================ */
268
+
269
+ :root {
270
+ /* ---- Hard pixel shadows (offset, zero blur) — warm, on paper ---- */
271
+ --shadow-pixel: 4px 4px 0 0 rgba(36,31,23,0.28);
272
+ --shadow-pixel-lg: 8px 8px 0 0 rgba(36,31,23,0.26);
273
+ --shadow-pixel-sm: 2px 2px 0 0 rgba(36,31,23,0.30);
274
+
275
+ /* ---- Accent glow — kept SUBTLE in daylight (small, low-alpha) ---- */
276
+ --glow-magenta: 0 0 7px rgba(192,57,143,0.35);
277
+ --glow-cyan: 0 0 7px rgba(31,127,174,0.35);
278
+ --glow-lime: 0 0 7px rgba(63,154,63,0.35);
279
+ --glow-soft: 0 0 6px rgba(120,100,70,0.25);
280
+
281
+ /* text glow for arcade marquee headings — now a soft warm halo */
282
+ --text-glow-magenta: 0 0 5px rgba(192,57,143,0.28);
283
+ --text-glow-cyan: 0 0 5px rgba(31,127,174,0.28);
284
+ --text-glow-lime: 0 0 5px rgba(63,154,63,0.28);
285
+
286
+ /* ---- Focus ---- */
287
+ --focus-shadow: 0 0 0 2px var(--bds-white), 0 0 0 4px var(--focus-ring), 0 0 6px rgba(31,127,174,0.4);
288
+
289
+ /* ---- CRT scanline overlay ----
290
+ Apply as a fixed/absolute ::after layer. Two-layer:
291
+ horizontal scanlines + a faint vignette. pointer-events:none. */
292
+ /* DAYLIGHT re-skin: CRT is retired. These are neutralized to
293
+ transparent so any leftover .crt-overlay / [data-crt] renders
294
+ nothing (kept as vars so old markup doesn't error). */
295
+ --crt-scanline-size: 3px; /* line period */
296
+ --crt-scanline: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0)); /* @kind other */
297
+ --crt-tint: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0));
298
+
299
+ /* ---- Motion ---- */
300
+ --ease-step: steps(4, end); /* @kind other */
301
+ --ease-pop: cubic-bezier(0.2,1.4,0.4,1); /* @kind other */
302
+ --dur-fast: 90ms; /* @kind other */
303
+ --dur: 160ms; /* @kind other */
304
+ --dur-slow: 320ms; /* @kind other */
305
+ }
306
+
307
+ /* ---------- Reusable CRT overlay ----------
308
+ Put <div class="crt-overlay"></div> as a fixed child of a
309
+ relatively/fixed positioned root, OR add data-crt to a box. */
310
+ .crt-overlay,
311
+ [data-crt]::after {
312
+ content: "";
313
+ position: absolute;
314
+ inset: 0;
315
+ pointer-events: none;
316
+ z-index: 9999;
317
+ background: var(--crt-scanline), var(--crt-tint);
318
+ mix-blend-mode: multiply;
319
+ }
320
+ [data-crt] { position: relative; }
321
+
322
+ /* faint flicker — disabled under reduced motion */
323
+ @media (prefers-reduced-motion: no-preference) {
324
+ @keyframes bds-flicker {
325
+ 0%, 97%, 100% { opacity: 1; }
326
+ 98% { opacity: 0.82; }
327
+ 99% { opacity: 0.94; }
328
+ }
329
+ .crt-overlay { animation: bds-flicker 6s steps(1,end) infinite; }
330
+ }
331
+
332
+ /* ---------- Pixel-perfect rendering helper ---------- */
333
+ [data-pixel-img] {
334
+ image-rendering: pixelated;
335
+ image-rendering: crisp-edges;
336
+ }
337
+
static/fonts/PressStart2P-Regular.woff2 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:854e91989d45c8148a3c17b67e0ec0925012db61fe8d7a9e04593883f105db72
3
+ size 4716
static/index.html ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
6
+ <title>BRAD DID SOMETHING</title>
7
+ <link rel="stylesheet" href="/static/css/tokens.css">
8
+ <link rel="stylesheet" href="/static/css/game.css">
9
+ </head>
10
+ <body>
11
+ <div id="viewport">
12
+ <header id="wordmark">
13
+ <span class="wm-main">BRAD DID SOMETHING</span>
14
+ <span class="wm-sub">VELOURA TECHNOLOGIES · Q3</span>
15
+ </header>
16
+
17
+ <div id="hud" class="hidden">
18
+ <div id="hud-revenue">
19
+ <div class="hud-row">
20
+ <span id="rev-number">$0</span><span id="rev-target">/ $1,000,000</span>
21
+ </div>
22
+ <div id="rev-bar"><div id="rev-fill"></div></div>
23
+ <div id="boss-title">HEAD OF SALES AND PARTNERSHIPS</div>
24
+ </div>
25
+ <div id="hud-right">
26
+ <span id="crisis-counter">CRISES 0/15</span>
27
+ <span id="pocket">POCKET $3,000</span>
28
+ <span id="hr-badge" class="hidden">! HR</span>
29
+ </div>
30
+ <div id="hud-banner" class="hidden"></div>
31
+ </div>
32
+
33
+ <main id="frame">
34
+ <div id="stage-wrap">
35
+ <div id="stage">
36
+ <canvas id="floor" width="640" height="480"></canvas>
37
+ <div id="world"></div>
38
+ <div id="fx"></div>
39
+ <div id="boardroom" class="hidden"></div>
40
+ <div id="dim" class="hidden"></div>
41
+ <div id="flash"></div>
42
+ <div id="edge-pulse"></div>
43
+ <div id="wipe"></div>
44
+ <div id="stamp" class="hidden">HR ALERT</div>
45
+ </div>
46
+ </div>
47
+ <aside id="papertrail">
48
+ <div class="pt-head">PAPER TRAIL</div>
49
+ <div id="pt-entries"><div class="pt-empty">&gt; nothing is on fire. yet.</div></div>
50
+ </aside>
51
+ </main>
52
+
53
+ <div id="dialogue" class="hidden"></div>
54
+ <div id="comic" class="hidden"></div>
55
+ <div id="gift-panel" class="hidden"></div>
56
+
57
+ <div id="title-screen">
58
+ <div class="ts-card">
59
+ <div class="ts-logo">BRAD DID<br>SOMETHING</div>
60
+ <div class="ts-sub">VELOURA TECHNOLOGIES</div>
61
+ <p class="ts-premise">You are the Head of Sales and Partnerships. Your team
62
+ is enthusiastic, well-intentioned, and completely unhinged. Hit $1,000,000
63
+ this quarter. Your people will keep happening.</p>
64
+ <div id="ts-cast"></div>
65
+ <button id="btn-start" class="btn-cta">START Q3 &#9654;</button>
66
+ <div class="ts-keys">&gt; WASD move &middot; SPACE talk &middot; G gift &middot; 1/2 choose &middot; ENTER send</div>
67
+ </div>
68
+ </div>
69
+
70
+ <div id="review-screen" class="hidden"></div>
71
+ <div id="crt"></div>
72
+ </div>
73
+
74
+ <script src="/static/js/chibi.js"></script>
75
+ <script type="module" src="/static/js/main.js"></script>
76
+ </body>
77
+ </html>
static/js/api.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // fetch wrappers for the /api endpoints (SCHEMAS.md HTTP contracts)
2
+ async function post(path, body) {
3
+ const resp = await fetch(path, {
4
+ method: "POST",
5
+ headers: { "Content-Type": "application/json" },
6
+ body: JSON.stringify(body || {}),
7
+ });
8
+ if (!resp.ok) {
9
+ const detail = await resp.json().catch(() => ({}));
10
+ const err = new Error(detail.detail || `http ${resp.status}`);
11
+ err.status = resp.status;
12
+ throw err;
13
+ }
14
+ return resp.json();
15
+ }
16
+
17
+ export const warm = () => post("/api/warm");
18
+ export const newGame = () => post("/api/new_game");
19
+ export const nextEvent = (sid) => post("/api/next_event", { session_id: sid });
20
+ export const respond = (sid, response_type, text) =>
21
+ post("/api/respond", { session_id: sid, response_type, text: text || "" });
22
+ export const presentationRound = (sid, response_type, text) =>
23
+ post("/api/presentation_round",
24
+ { session_id: sid, response_type: response_type || "custom", text: text || "" });
25
+ export const gift = (sid, npc_id, tier) =>
26
+ post("/api/gift", { session_id: sid, npc_id, tier });
27
+ export const review = (sid) => post("/api/review", { session_id: sid });
28
+ export const chat = (sid, npc_id, text) =>
29
+ post("/api/chat", { session_id: sid, npc_id, text: text || "" });
30
+ export const idle = (sid) => post("/api/idle", { session_id: sid });
31
+ export const readEmail = (sid) => post("/api/read_email", { session_id: sid });
32
+ export const comic = (sid, image_prompt) =>
33
+ post("/api/comic", { session_id: sid, image_prompt: image_prompt || "" });
static/js/audio.js ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Cozy WebAudio sound — warm and quiet, not techy. Sine/triangle voices run
2
+ // through a soft lowpass + a little reverb; notes are pentatonic so nothing
3
+ // ever clashes; envelopes fade in/out so there are no clicks. Low master gain.
4
+ // No audio files. Mute with M.
5
+
6
+ let ctx = null, bus = null, master = null, muted = false, dead = false;
7
+ const MASTER = 0.32; // overall cosiness ceiling — everything sits under this
8
+
9
+ // looping background music (mp3 in static/audio/). Routed past the SFX
10
+ // lowpass/reverb so it stays full, but still under master + M mute.
11
+ const MUSIC_SRC = "/static/audio/bgm.mp3";
12
+ const MUSIC_VOL = 0.15; // sits gently under the SFX — tune to taste
13
+ let musicEl = null, musicGain = null, musicWired = false;
14
+
15
+ function ac() {
16
+ if (dead) return null;
17
+ try {
18
+ if (!ctx) {
19
+ ctx = new (window.AudioContext || window.webkitAudioContext)();
20
+ master = ctx.createGain();
21
+ master.gain.value = MASTER;
22
+ master.connect(ctx.destination);
23
+ // warm everything: a gentle lowpass shaves the harsh top end
24
+ const lp = ctx.createBiquadFilter();
25
+ lp.type = "lowpass"; lp.frequency.value = 2600; lp.Q.value = 0.5;
26
+ lp.connect(master);
27
+ // a little room: short generated reverb, mixed in low
28
+ const wet = ctx.createGain(); wet.gain.value = 0.16;
29
+ const verb = ctx.createConvolver(); verb.buffer = impulse(0.9, 2.6);
30
+ verb.connect(wet); wet.connect(master);
31
+ bus = ctx.createGain(); bus.gain.value = 1;
32
+ bus.connect(lp); // dry
33
+ bus.connect(verb); // wet
34
+ }
35
+ if (ctx.state === "suspended") ctx.resume();
36
+ return ctx;
37
+ } catch {
38
+ dead = true; // no audio device — game keeps working, silently
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function impulse(seconds, decay) {
44
+ const a = ctx, len = Math.floor(a.sampleRate * seconds);
45
+ const buf = a.createBuffer(2, len, a.sampleRate);
46
+ for (let ch = 0; ch < 2; ch++) {
47
+ const d = buf.getChannelData(ch);
48
+ for (let i = 0; i < len; i++)
49
+ d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
50
+ }
51
+ return buf;
52
+ }
53
+
54
+ // pentatonic C-major — any mix of these sounds pleasant and warm
55
+ const N = {
56
+ C3: 130.81, E3: 164.81, G3: 196.0,
57
+ C4: 261.63, D4: 293.66, E4: 329.63, G4: 392.0, A4: 440.0,
58
+ C5: 523.25, D5: 587.33, E5: 659.25, G5: 783.99, A5: 880.0,
59
+ C6: 1046.5, D6: 1174.7, E6: 1318.5, G6: 1568.0,
60
+ };
61
+
62
+ // one soft voice with a click-free envelope
63
+ function tone(freq, o = {}) {
64
+ if (muted) return;
65
+ const a = ac(), t0 = a.currentTime + (o.t || 0);
66
+ const dur = o.dur || 0.2, atk = o.attack || 0.012, rel = o.rel || dur * 0.85;
67
+ const osc = a.createOscillator();
68
+ osc.type = o.type || "sine";
69
+ osc.frequency.setValueAtTime(freq, t0);
70
+ if (o.glideTo) osc.frequency.exponentialRampToValueAtTime(o.glideTo, t0 + dur);
71
+ if (o.detune) osc.detune.value = o.detune;
72
+ const g = a.createGain();
73
+ g.gain.setValueAtTime(0.0001, t0);
74
+ g.gain.exponentialRampToValueAtTime(o.gain || 0.1, t0 + atk);
75
+ g.gain.exponentialRampToValueAtTime(0.0001, t0 + atk + rel);
76
+ osc.connect(g).connect(bus);
77
+ osc.start(t0); osc.stop(t0 + atk + rel + 0.05);
78
+ // a faint octave/fifth layer warms plucks without making them louder
79
+ if (o.warm) tone(freq * o.warm, { ...o, warm: 0, gain: (o.gain || 0.1) * 0.35 });
80
+ }
81
+
82
+ // filtered noise — footsteps, paper, whooshes (cloth/wood, never beeps)
83
+ function noise(o = {}) {
84
+ if (muted) return;
85
+ const a = ac(), t0 = a.currentTime + (o.t || 0), dur = o.dur || 0.08;
86
+ const buf = a.createBuffer(1, Math.floor(a.sampleRate * dur), a.sampleRate);
87
+ const d = buf.getChannelData(0);
88
+ for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
89
+ const src = a.createBufferSource(); src.buffer = buf;
90
+ const f = a.createBiquadFilter();
91
+ f.type = o.filter || "lowpass";
92
+ f.frequency.setValueAtTime(o.cutoff || 700, t0);
93
+ f.Q.value = o.q || 0.7;
94
+ if (o.sweep) f.frequency.linearRampToValueAtTime(o.sweep, t0 + dur);
95
+ const g = a.createGain();
96
+ g.gain.setValueAtTime(0.0001, t0);
97
+ g.gain.exponentialRampToValueAtTime(o.gain || 0.04, t0 + (o.attack || 0.005));
98
+ g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
99
+ src.connect(f).connect(g).connect(bus);
100
+ src.start(t0); src.stop(t0 + dur + 0.02);
101
+ }
102
+
103
+ // play a little melody/arpeggio
104
+ function seq(freqs, gap, o = {}) {
105
+ freqs.forEach((f, i) => tone(f, { ...o, t: (o.t || 0) + i * gap }));
106
+ }
107
+
108
+ // ---- ambient office bed: a near-silent warm drone, always running ----
109
+ let bed = null;
110
+ function ambientStart() {
111
+ if (bed) return;
112
+ const a = ac();
113
+ const g = a.createGain(); g.gain.value = 0.0001; g.connect(bus);
114
+ g.gain.exponentialRampToValueAtTime(0.013, a.currentTime + 3);
115
+ const f = a.createBiquadFilter();
116
+ f.type = "lowpass"; f.frequency.value = 380; f.Q.value = 0.4; f.connect(g);
117
+ const o1 = a.createOscillator(); o1.type = "sine"; o1.frequency.value = N.C3;
118
+ const o2 = a.createOscillator(); o2.type = "sine"; o2.frequency.value = N.C3 + 0.6;
119
+ const o3 = a.createOscillator(); o3.type = "sine"; o3.frequency.value = N.G3;
120
+ const g3 = a.createGain(); g3.gain.value = 0.4;
121
+ o1.connect(f); o2.connect(f); o3.connect(g3).connect(f);
122
+ // a very slow filter drift so it breathes
123
+ const lfo = a.createOscillator(); lfo.frequency.value = 0.06;
124
+ const lg = a.createGain(); lg.gain.value = 60;
125
+ lfo.connect(lg).connect(f.frequency);
126
+ [o1, o2, o3, lfo].forEach((x) => x.start());
127
+ bed = { g, nodes: [o1, o2, o3, lfo] };
128
+ }
129
+ function ambientStop() {
130
+ if (!bed) return;
131
+ const a = ac(), { g, nodes } = bed;
132
+ g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime + 1.2);
133
+ nodes.forEach((x) => { try { x.stop(a.currentTime + 1.4); } catch {} });
134
+ bed = null;
135
+ }
136
+
137
+ // ---- looping background music (mp3) ----
138
+ function musicStart() {
139
+ try {
140
+ if (!musicEl) {
141
+ musicEl = new Audio(MUSIC_SRC);
142
+ musicEl.loop = true; musicEl.preload = "auto";
143
+ musicEl.style.display = "none";
144
+ document.body.appendChild(musicEl); // attach for robustness + inspection
145
+ }
146
+ musicEl.muted = muted;
147
+ const a = ac();
148
+ if (a && !musicWired) {
149
+ try {
150
+ const src = a.createMediaElementSource(musicEl);
151
+ musicGain = a.createGain(); musicGain.gain.value = 0.0001;
152
+ src.connect(musicGain).connect(master); // past the SFX lowpass/reverb
153
+ musicGain.gain.exponentialRampToValueAtTime(MUSIC_VOL, a.currentTime + 2.5);
154
+ musicWired = true;
155
+ } catch { musicEl.volume = MUSIC_VOL; }
156
+ } else if (!a) {
157
+ musicEl.volume = MUSIC_VOL; // no WebAudio — plain element
158
+ } else if (musicGain) {
159
+ musicGain.gain.exponentialRampToValueAtTime(MUSIC_VOL, a.currentTime + 1.5);
160
+ }
161
+ const p = musicEl.play();
162
+ if (p && p.catch) p.catch(() => {}); // autoplay block — ignore
163
+ } catch {}
164
+ }
165
+ function musicStop() {
166
+ if (!musicEl) return;
167
+ try {
168
+ if (musicGain && ctx) {
169
+ musicGain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 1.2);
170
+ setTimeout(() => { try { musicEl.pause(); } catch {} }, 1300);
171
+ } else { musicEl.pause(); }
172
+ } catch {}
173
+ }
174
+
175
+ // ---- rate limiters so footsteps / typing never stack into a buzz ----
176
+ let lastFoot = 0, footToggle = 0, lastType = 0;
177
+
178
+ export const sfx = {
179
+ // movement & ambient
180
+ footstep() {
181
+ const now = performance.now();
182
+ if (now - lastFoot < 250) return;
183
+ lastFoot = now; footToggle ^= 1;
184
+ noise({ dur: 0.06, cutoff: footToggle ? 540 : 600, q: 1.1, gain: 0.022 });
185
+ tone(footToggle ? N.C3 : N.E3, { type: "sine", dur: 0.05, gain: 0.012 });
186
+ },
187
+ bump() { noise({ dur: 0.11, cutoff: 300, gain: 0.03 });
188
+ tone(90, { type: "sine", dur: 0.12, gain: 0.035, glideTo: 70 }); },
189
+ ambientStart, ambientStop, musicStart, musicStop,
190
+
191
+ // dialogue & UI
192
+ open() { seq([N.E5, N.A5], 0.06, { type: "sine", gain: 0.06, dur: 0.22 }); },
193
+ close() { seq([N.A5, N.E5], 0.06, { type: "sine", gain: 0.045, dur: 0.2 }); },
194
+ type() {
195
+ const now = performance.now();
196
+ if (now - lastType < 55) return;
197
+ lastType = now;
198
+ tone(350 + Math.random() * 36, { type: "triangle", dur: 0.035,
199
+ gain: 0.016, attack: 0.002 });
200
+ },
201
+ click() { tone(N.A5, { type: "triangle", dur: 0.1, gain: 0.05, warm: 0.5 }); },
202
+ send() { seq([N.G5, N.C6], 0.05, { type: "sine", gain: 0.06, dur: 0.16 }); },
203
+ prompt() { tone(N.D6, { type: "sine", dur: 0.12, gain: 0.028 }); },
204
+ comic() { noise({ dur: 0.34, cutoff: 420, sweep: 1800, gain: 0.04 }); // page reveal
205
+ seq([N.G5, N.C6, N.E6], 0.07, { type: "sine", gain: 0.04, dur: 0.26, warm: 0.5 }); },
206
+
207
+ // event arrivals
208
+ bubble() { tone(N.E5, { type: "sine", dur: 0.2, gain: 0.06, glideTo: N.A5 }); },
209
+ amberBubble() { tone(N.D5, { type: "sine", dur: 0.22, gain: 0.05, glideTo: N.G4 }); },
210
+ heart() { seq([N.E5, N.A5, N.C6], 0.07, { type: "sine", gain: 0.045, dur: 0.24 });
211
+ tone(N.E6, { t: 0.2, dur: 0.32, gain: 0.022 }); },
212
+ newspaper() { noise({ dur: 0.4, cutoff: 380, sweep: 1700, gain: 0.04 });
213
+ noise({ t: 0.32, dur: 0.05, cutoff: 2400, gain: 0.03 });
214
+ noise({ t: 0.4, dur: 0.05, cutoff: 2000, gain: 0.025 }); },
215
+ envelope() { noise({ dur: 0.3, cutoff: 1100, sweep: 520, gain: 0.035,
216
+ filter: "bandpass", q: 0.8 }); },
217
+ mail() { seq([N.G5, N.C6], 0.13, { type: "sine", gain: 0.05, dur: 0.32, warm: 0.5 }); },
218
+ phoneRingStart() {
219
+ if (sfx._ring) return;
220
+ const ring = () => { seq([N.E5, N.G5], 0.12, { type: "sine", gain: 0.04, dur: 0.22 });
221
+ seq([N.E5, N.G5], 0.12, { type: "sine", gain: 0.04, dur: 0.22, t: 0.3 }); };
222
+ ring(); sfx._ring = setInterval(ring, 1400);
223
+ },
224
+ phoneRingStop() { if (sfx._ring) { clearInterval(sfx._ring); sfx._ring = null; } },
225
+
226
+ // outcomes
227
+ moneyUp() { seq([N.C5, N.E5, N.G5, N.C6], 0.06,
228
+ { type: "triangle", gain: 0.055, dur: 0.24, warm: 0.5 }); },
229
+ moneyDown() { seq([N.A5, N.G5, N.E5, N.C5], 0.07,
230
+ { type: "sine", gain: 0.05, dur: 0.26 }); },
231
+ disaster() { noise({ dur: 0.28, cutoff: 220, gain: 0.05 });
232
+ tone(72, { type: "sine", dur: 0.4, gain: 0.05, glideTo: 54 }); },
233
+ titleSwap() { seq([N.D6, N.G6], 0.04, { type: "sine", gain: 0.02, dur: 0.12 }); },
234
+ trail() { noise({ dur: 0.04, cutoff: 3200, gain: 0.013, filter: "highpass" }); },
235
+ gift() { seq([N.G5, N.A5, N.C6], 0.05, { type: "triangle", gain: 0.045, dur: 0.2 }); },
236
+ coffee() { [N.C5, N.E5, N.G5].forEach((f) =>
237
+ tone(f, { type: "sine", gain: 0.038, dur: 0.5 })); },
238
+
239
+ // boardroom / presentation
240
+ gold() { seq([N.G5, N.D6, N.G6], 0.08, { type: "sine", gain: 0.045, dur: 0.5 }); },
241
+ wipe() { noise({ dur: 0.35, cutoff: 550, sweep: 1900, gain: 0.035 }); },
242
+ boardIn() { noise({ dur: 0.08, cutoff: 380, gain: 0.03 });
243
+ noise({ t: 0.16, dur: 0.08, cutoff: 420, gain: 0.03 }); },
244
+ wrongSlide() { tone(N.G5, { type: "triangle", dur: 0.42, gain: 0.06, glideTo: N.D5 });
245
+ tone(N.E5, { t: 0.4, type: "triangle", dur: 0.32, gain: 0.05, glideTo: N.C5 }); },
246
+ score() { seq([N.C5, N.E5, N.G5], 0.08, { type: "triangle", gain: 0.05, dur: 0.2 });
247
+ tone(N.C6, { t: 0.28, dur: 0.5, gain: 0.05, warm: 0.5 }); },
248
+ stamp() { noise({ dur: 0.18, cutoff: 280, gain: 0.05 });
249
+ tone(110, { type: "sine", dur: 0.26, gain: 0.05, glideTo: 88 }); },
250
+
251
+ // endings
252
+ win() { seq([N.C5, N.E5, N.G5, N.C6, N.E6, N.G6], 0.09,
253
+ { type: "triangle", gain: 0.055, dur: 0.42, warm: 0.5 });
254
+ [N.C5, N.E5, N.G5].forEach((f) => tone(f, { t: 0.6, dur: 0.9, gain: 0.04 })); },
255
+ lose(tier) {
256
+ const sets = {
257
+ hit_target: [N.C5, N.E5, N.G5], above_600k: [N.A4, N.G4, N.E4],
258
+ "300k_to_600k": [N.G4, N.E4, N.C4], below_300k: [N.E4, N.D4, N.C4, N.C3],
259
+ };
260
+ seq(sets[tier] || sets["300k_to_600k"], 0.22,
261
+ { type: "sine", gain: 0.05, dur: 0.4 }); },
262
+ confetti() { for (let i = 0; i < 12; i++) {
263
+ const notes = [N.C6, N.D6, N.E6, N.G6, N.A5];
264
+ tone(notes[(Math.random() * notes.length) | 0],
265
+ { t: Math.random() * 1.1, type: "sine", dur: 0.18, gain: 0.03 }); } },
266
+ review() { [N.C4, N.G4, N.C5].forEach((f, i) =>
267
+ tone(f, { type: "sine", dur: 1.1, gain: 0.035, t: i * 0.08 })); },
268
+ play() { seq([N.G5, N.C6], 0.07, { type: "sine", gain: 0.05, dur: 0.25 }); },
269
+
270
+ toggleMute() {
271
+ muted = !muted;
272
+ if (master) master.gain.exponentialRampToValueAtTime(
273
+ muted ? 0.0001 : MASTER, (ctx ? ctx.currentTime : 0) + 0.15);
274
+ if (musicEl) musicEl.muted = muted; // covers both routed + plain-element paths
275
+ return muted;
276
+ },
277
+ };
static/js/boardroom.js ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // boardroom interior scene (UI_UX.md §6 §17 §21)
2
+ import { G } from "./state.js";
3
+ import { chibiInBox } from "./sprites.js";
4
+
5
+ // presenting NPC expression per PRESENTATION_SYSTEM.md state
6
+ const PRESENT_EXPRESSION = {
7
+ romance: { eyes: "heart", blush: true, mouth: "smile" },
8
+ grief: { eyes: "closed", mouth: "frown", tilt: 4 },
9
+ overprepared: { armPose: "point", mouth: "open" },
10
+ bare_minimum: { armPose: "crossed", mouth: "flat" },
11
+ advocate: { eyes: "happy", mouth: "smile", armPose: "open" },
12
+ normal: { armPose: "point", mouth: "smirk" },
13
+ };
14
+
15
+ const HAIRS = ["#4a485e", "#6b6347", "#3a3850"];
16
+
17
+ function slideHtml(kind) {
18
+ if (kind === "pie140") {
19
+ return `<div style="text-align:center;font-size:6px;color:#9a92c4;">
20
+ <div style="position:relative;width:54px;height:54px;margin:4px auto;
21
+ border-radius:50%;background:conic-gradient(
22
+ var(--bds-kevin) 0 115deg, var(--bds-neon-magenta) 115deg 215deg,
23
+ var(--bds-brad) 215deg 305deg, var(--bds-stacey) 305deg 360deg);
24
+ box-shadow:0 0 0 2px var(--bds-void), 6px -4px 0 -1px var(--bds-kevin);">
25
+ </div>
26
+ <span style="color:var(--bds-brad)">TOTAL: 140%</span></div>`;
27
+ }
28
+ if (kind === "momentum")
29
+ return `<div style="text-align:center;font-size:9px;color:var(--bds-white);
30
+ padding-top:18px;">MOMENTUM<br><span style="font-size:6px;
31
+ color:#9a92c4;">(Trust Us)</span></div>`;
32
+ return `<div style="text-align:center;padding-top:10px;">
33
+ <div style="display:inline-block;width:46px;height:24px;
34
+ background:linear-gradient(#190f33 60%, #ff9c3c 60%);"></div>
35
+ <div style="font-size:5px;color:#9a92c4;margin-top:4px;">[no title]</div></div>`;
36
+ }
37
+
38
+ export function enterBoardroom(presentingNpc, npcState) {
39
+ const room = G.els.boardroom;
40
+ const chairAt = (x, y) => `
41
+ <div style="position:absolute;left:${x}px;top:${y}px;width:26px;height:20px;
42
+ background:#42424d;border:2px solid #2c2c38;border-top:4px solid #5b5b68;
43
+ box-shadow:0 4px 0 rgba(70,70,95,.3);"></div>`;
44
+ const windowAt = (x, w) => `
45
+ <div style="position:absolute;left:${x}px;top:8px;width:${w}px;height:34px;
46
+ background:#aedcf2;border:3px solid #8e94a6;overflow:hidden;">
47
+ <div style="position:absolute;left:0;top:0;width:100%;height:10px;background:#cdeaf8;"></div>
48
+ <div style="position:absolute;left:10px;top:5px;width:16px;height:4px;background:#ffffff;"></div>
49
+ <div style="position:absolute;left:6px;bottom:0;width:14px;height:16px;background:#9fb2c4;"></div>
50
+ <div style="position:absolute;left:26px;bottom:0;width:12px;height:22px;background:#9fb2c4;"></div>
51
+ <div style="position:absolute;left:44px;bottom:0;width:16px;height:12px;background:#9fb2c4;"></div>
52
+ <div style="position:absolute;left:30px;bottom:12px;width:3px;height:3px;background:#fffbe8;"></div>
53
+ </div>`;
54
+ room.innerHTML = `
55
+ <!-- warm meeting-room carpet -->
56
+ <div style="position:absolute;inset:0;background:
57
+ repeating-linear-gradient(0deg,#9b9183 0 16px,#948a7c 16px 32px),
58
+ repeating-linear-gradient(90deg,transparent 0 16px,rgba(0,0,0,.05) 16px 32px);
59
+ "></div>
60
+ <!-- area rug under the table -->
61
+ <div style="position:absolute;left:140px;top:120px;width:360px;height:180px;
62
+ background:#c4664e;border:6px solid #b6543e;"></div>
63
+ <!-- white top wall with daylight windows -->
64
+ <div style="position:absolute;left:0;top:0;width:100%;height:48px;
65
+ background:#f3f3f5;border-bottom:4px solid #aaaab6;
66
+ box-shadow:inset 0 4px 0 #fbfbfd, inset 0 -8px 0 #e2e2e8;"></div>
67
+ ${windowAt(40, 70)}${windowAt(470, 70)}
68
+ <!-- conference table (z 2: covers the legs of seated board chibis) -->
69
+ <div style="position:absolute;left:170px;top:150px;width:300px;height:120px;
70
+ z-index:2;
71
+ background:#6b4f2e;border:3px solid #2e2113;border-top:6px solid #83643c;
72
+ border-bottom:8px solid #4e3920;box-shadow:10px 12px 0 rgba(5,4,14,.45);">
73
+ <div style="position:absolute;left:24px;top:22px;width:26px;height:18px;
74
+ background:#cfcadf;box-shadow:3px 3px 0 #8d87a8;"></div>
75
+ <div style="position:absolute;right:40px;top:54px;width:26px;height:18px;
76
+ background:#cfcadf;box-shadow:3px 3px 0 #8d87a8;"></div>
77
+ <div style="position:absolute;left:130px;top:44px;width:36px;height:22px;
78
+ background:#454060;border-top:3px solid #575278;"></div>
79
+ <div style="position:absolute;left:70px;top:70px;width:12px;height:14px;
80
+ background:#ff5a3c;border-top:2px solid #ffa28e;"></div>
81
+ </div>
82
+ ${chairAt(210, 122)}${chairAt(300, 122)}${chairAt(390, 122)}
83
+ ${chairAt(210, 282)}${chairAt(300, 282)}${chairAt(390, 282)}
84
+ ${chairAt(132, 196)}
85
+ <!-- projector screen -->
86
+ <div id="projector" style="position:absolute;left:240px;top:30px;width:160px;
87
+ height:92px;background:var(--bds-navy-800);border:4px solid #5d5d68;
88
+ border-bottom:8px solid #44444f;
89
+ box-shadow:0 0 22px rgba(127,178,236,.35), 6px 8px 0 rgba(70,70,95,.3);">
90
+ ${slideHtml("pie140")}</div>
91
+ <!-- glass wall, left -->
92
+ <div style="position:absolute;left:20px;top:60px;width:10px;height:330px;
93
+ background:rgba(140,200,235,.3);border-left:3px solid #8e94a6;
94
+ border-right:3px solid #8e94a6;"></div>
95
+ <div style="position:absolute;left:22px;top:140px;width:6px;height:2px;background:rgba(255,255,255,.7);"></div>
96
+ <div style="position:absolute;left:22px;top:260px;width:6px;height:2px;background:rgba(255,255,255,.7);"></div>
97
+ <!-- corner plant -->
98
+ <div style="position:absolute;right:30px;bottom:36px;width:18px;height:14px;
99
+ background:#b35c3a;border-top:3px solid #cf7450;"></div>
100
+ <div style="position:absolute;right:26px;bottom:48px;width:11px;height:18px;
101
+ background:#3f9e52;"></div>
102
+ <div style="position:absolute;right:38px;bottom:54px;width:9px;height:22px;
103
+ background:#5cc46e;"></div>
104
+ <div id="kevin-glass" style="position:absolute;left:2px;top:240px;"></div>
105
+ <div id="board-seats" style="position:absolute;left:0;top:0;width:100%;
106
+ height:100%;"></div>
107
+ <div style="position:absolute;right:14px;bottom:10px;font-size:6px;
108
+ color:var(--bds-grey-dim);">&gt; kevin watching (always)</div>`;
109
+ room.classList.remove("hidden");
110
+
111
+ // board members file in one at a time — full designed chibis, seated
112
+ // behind the table (the table's z-index covers their legs)
113
+ const seats = room.querySelector("#board-seats");
114
+ seats.style.zIndex = "1";
115
+ [188, 288, 388].forEach((x, i) => {
116
+ setTimeout(() => {
117
+ const member = chibiInBox("board", {
118
+ face: { hair: HAIRS[i], woman: i === 1 },
119
+ mouth: i === 2 ? "frown" : "flat",
120
+ }, 70, 78);
121
+ member.style.position = "absolute";
122
+ member.style.left = `${x}px`;
123
+ member.style.top = "100px";
124
+ seats.appendChild(member);
125
+ }, 350 * i);
126
+ });
127
+
128
+ // presenting NPC at the projector — their emotional state shows
129
+ const colorVar = `var(--bds-${presentingNpc})`;
130
+ const npc = chibiInBox(presentingNpc, PRESENT_EXPRESSION[npcState] ||
131
+ PRESENT_EXPRESSION.normal, 80, 86);
132
+ npc.style.position = "absolute";
133
+ npc.style.left = "150px"; npc.style.top = "44px";
134
+ npc.style.zIndex = "3";
135
+ if (npcState === "grief") npc.style.filter = "saturate(.55) brightness(.85)";
136
+ seats.appendChild(npc);
137
+ const tagEl = window.BDSChibi.tag(
138
+ presentingNpc.toUpperCase(), colorVar, { top: 0 });
139
+ tagEl.style.left = "190px"; tagEl.style.top = "134px";
140
+ tagEl.style.transform = "none"; tagEl.style.zIndex = "3";
141
+ seats.appendChild(tagEl);
142
+
143
+ // kevin through the glass (unless kevin is presenting — then derek watches)
144
+ const watcher = presentingNpc === "kevin" ? "derek" : "kevin";
145
+ const kv = window.BDSChibi.topSprite({ who: watcher, pose: "lean" });
146
+ kv.style.opacity = ".55";
147
+ room.querySelector("#kevin-glass").appendChild(kv);
148
+
149
+ return {
150
+ setSlide(kind) {
151
+ room.querySelector("#projector").innerHTML = slideHtml(kind);
152
+ },
153
+ };
154
+ }
155
+
156
+ export function exitBoardroom() {
157
+ G.els.boardroom.classList.add("hidden");
158
+ G.els.boardroom.innerHTML = "";
159
+ }
static/js/chibi.js ADDED
@@ -0,0 +1,526 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ chibi.js — shared sprite engine for Brad Did Something
3
+ Ported from "Character Sprite Sheet.html" v1.0 and extended
4
+ with v2 emotional/talk poses, top-down floor sprites and a
5
+ box-shadow pixel-glyph drawer (hearts, stars, drops, Z).
6
+ Vanilla JS. Load with <script src="../assets/chibi.js">.
7
+ Exposes window.BDSChibi.
8
+ ============================================================ */
9
+ (function () {
10
+ const DARK = "var(--bds-void)";
11
+
12
+ /* ---------- shared styles (injected once) ---------- */
13
+ const css = `
14
+ .chibi { position: relative; transform-origin: bottom center; z-index: 2; }
15
+ .chibi * { position: absolute; box-sizing: border-box; }
16
+ .chibi .part { border: 3px solid var(--bds-void); }
17
+ .chibi .head { border-radius: 44% 44% 46% 46%; z-index: 4; }
18
+ .chibi .hairpc { border: 3px solid var(--bds-void); z-index: 6; }
19
+ .chibi .collar { border-radius: 50%; z-index: 5; }
20
+ .chibi .torso { border-radius: 42% 42% 26% 26%; z-index: 3; }
21
+ .chibi .arm { border-radius: 45%; z-index: 2; transform-origin: top center; }
22
+ .chibi .hand { border-radius: 50%; z-index: 3; }
23
+ .chibi .leg { border-radius: 22% 22% 30% 30%; z-index: 3; }
24
+ .chibi .eye { background: var(--bds-void); border-radius: 50%; z-index: 7; }
25
+ .chibi .brow { z-index: 8; border-radius: 2px; }
26
+ .chibi .lash { background: var(--bds-void); z-index: 8; }
27
+ .chibi .glasses{ border: 3px solid var(--bds-void); background: transparent; border-radius: 36%; z-index: 8; }
28
+ .chibi .bridge { background: var(--bds-void); z-index: 8; }
29
+ .chibi .mouth { z-index: 7; }
30
+ .chibi .blush { z-index: 7; border-radius: 50%; opacity: .8; }
31
+ .chibi .sweat { background: var(--bds-stacey); border-radius: 50% 50% 50% 50% / 65% 65% 40% 40%; z-index: 9; box-shadow: 0 0 6px var(--bds-stacey); }
32
+ .chibi .pxglyph { z-index: 10; }
33
+
34
+ .bds-top-sprite { position: absolute; width: 28px; height: 34px; z-index: 5; transform-origin: 50% 90%; }
35
+ .bds-top-sprite > i { position: absolute; display: block; }
36
+
37
+ @media (prefers-reduced-motion: no-preference) {
38
+ .a-idle { animation: bds-c-idle 2.6s ease-in-out infinite; }
39
+ .a-bob { animation: bds-c-bob var(--spd,.5s) ease-in-out infinite; }
40
+ .a-walk .leg.l { animation: bds-c-lega var(--spd,.5s) ease-in-out infinite; }
41
+ .a-walk .leg.r { animation: bds-c-legb var(--spd,.5s) ease-in-out infinite; }
42
+ .a-walk .arm.l { animation: bds-c-arma var(--spd,.5s) ease-in-out infinite; }
43
+ .a-walk .arm.r { animation: bds-c-armb var(--spd,.5s) ease-in-out infinite; }
44
+ .a-crisis { animation: bds-c-jolt .45s ease-out infinite alternate; }
45
+ .a-rise { animation: bds-c-rise 3.6s ease-in-out infinite; }
46
+ .a-talkarm .arm.gesture { animation: bds-c-wave .7s ease-in-out infinite alternate; }
47
+ .a-mouth .mouth { animation: bds-c-flap .42s steps(2) infinite; }
48
+ .a-mouth-slow .mouth { animation: bds-c-flap 1.4s steps(2) infinite; }
49
+ .a-wring .arm.l { animation: bds-c-wra .55s ease-in-out infinite alternate; }
50
+ .a-wring .arm.r { animation: bds-c-wrb .55s ease-in-out infinite alternate; }
51
+ .a-type .arm.r { animation: bds-c-tap .3s steps(2) infinite; }
52
+ .a-nod .head, .a-nod .hairpc { animation: bds-c-nod 3s ease-in-out infinite; }
53
+ .a-shiver { animation: bds-c-shiver .22s steps(2) infinite; }
54
+ .a-bounce { animation: bds-c-bounce .6s var(--ease-pop, ease-out) infinite alternate; }
55
+ .a-peek { animation: bds-c-peek 3.2s steps(1,end) infinite; }
56
+ .a-tear .tearpx { animation: bds-c-tear 1.1s steps(3) infinite; }
57
+ .a-zzz .zpx { animation: bds-c-zzz 2.4s steps(4) infinite; }
58
+ .a-top-idle { animation: bds-top-idle 1.6s steps(2) infinite; }
59
+ .a-top-walk { animation: bds-top-walk var(--spd,.5s) ease-in-out infinite; }
60
+ /* articulated floor walk: legs step in opposition, arms counter-swing */
61
+ .a-top-walk .fl-leg-l { animation: bds-fl-stepA var(--spd,.5s) ease-in-out infinite; }
62
+ .a-top-walk .fl-leg-r { animation: bds-fl-stepB var(--spd,.5s) ease-in-out infinite; }
63
+ .a-top-walk .fl-arm-l { animation: bds-fl-swingA var(--spd,.5s) ease-in-out infinite; }
64
+ .a-top-walk .fl-arm-r { animation: bds-fl-swingB var(--spd,.5s) ease-in-out infinite; }
65
+ .a-top-shake { animation: bds-c-shiver .2s steps(2) infinite; }
66
+ }
67
+ @keyframes bds-c-idle { 0%,100%{ transform: translateY(0) rotate(0); } 50%{ transform: translateY(-1px) rotate(-1.5deg); } }
68
+ @keyframes bds-c-bob { 50% { transform: translateY(-3px); } }
69
+ @keyframes bds-c-lega { 50% { transform: translateY(-5px) rotate(8deg); } }
70
+ @keyframes bds-c-legb { 50% { transform: translateY(0) rotate(-8deg); } }
71
+ @keyframes bds-c-arma { 50% { transform: rotate(18deg); } }
72
+ @keyframes bds-c-armb { 50% { transform: rotate(-18deg); } }
73
+ @keyframes bds-c-jolt { from { transform: translateY(2px) rotate(1.5deg);} to { transform: translateY(-4px) rotate(-1.5deg);} }
74
+ @keyframes bds-c-rise { 0%,28%{ transform: translateY(12px);} 72%,100%{ transform: translateY(0);} }
75
+ @keyframes bds-c-wave { from{ transform: rotate(-12deg);} to{ transform: rotate(-58deg);} }
76
+ @keyframes bds-c-flap { 50% { transform: scaleY(.4); } }
77
+ @keyframes bds-c-wra { from{ transform: rotate(14deg);} to{ transform: rotate(30deg) translateY(-2px);} }
78
+ @keyframes bds-c-wrb { from{ transform: rotate(-14deg);} to{ transform: rotate(-30deg) translateY(-2px);} }
79
+ @keyframes bds-c-tap { 50% { transform: translateY(-3px); } }
80
+ @keyframes bds-c-nod { 0%,40%,100%{ transform: translateY(0);} 50%,58%{ transform: translateY(2px);} }
81
+ @keyframes bds-c-shiver { 50% { transform: translateX(2px); } }
82
+ @keyframes bds-c-bounce { to { transform: translateY(-5px); } }
83
+ @keyframes bds-c-peek { 0%,60%,100% { transform: translateX(0); } 70%,90% { transform: translateX(7px); } }
84
+ @keyframes bds-c-tear { 0%{ transform: translateY(0); opacity:1;} 100%{ transform: translateY(14px); opacity:0;} }
85
+ @keyframes bds-c-zzz { 0%{ transform: translate(0,0); opacity:0;} 25%{ opacity:1;} 100%{ transform: translate(8px,-16px); opacity:0;} }
86
+ @keyframes bds-top-idle { 50% { transform: translateY(-2px); } }
87
+ @keyframes bds-top-walk { 0%,100%{transform:translateY(0);} 25%,75%{transform:translateY(-2px);} 50%{transform:translateY(0);} }
88
+ @keyframes bds-fl-stepA { 0%,100%{transform:translateY(0);} 50%{transform:translateY(-3.5px);} }
89
+ @keyframes bds-fl-stepB { 0%,100%{transform:translateY(-3.5px);} 50%{transform:translateY(0);} }
90
+ @keyframes bds-fl-swingA { 0%,100%{transform:translateY(-1.5px);} 50%{transform:translateY(1px);} }
91
+ @keyframes bds-fl-swingB { 0%,100%{transform:translateY(1px);} 50%{transform:translateY(-1.5px);} }
92
+ `;
93
+ const styleEl = document.createElement("style");
94
+ styleEl.textContent = css;
95
+ document.head.appendChild(styleEl);
96
+
97
+ /* ---------- character registry (lowercase keys) ---------- */
98
+ const CHARS = {
99
+ player: { key:"PLAYER", color:"--bds-white", u:132, wf:1.00, bw:14, bh:22, hd:10, walk:".5s",
100
+ face:{ hair:"#5d5b86", style:"short", eyes:"dot", brows:null, mouth:"neutral", glasses:false, woman:false } },
101
+ brad: { key:"BRAD", color:"--bds-brad", u:134, wf:1.16, bw:18, bh:22, hd:11, walk:".34s",
102
+ face:{ hair:"#7a4626", style:"messy", eyes:"big", brows:"worried", mouth:"worried", glasses:false, woman:false, sweat:true } },
103
+ stacey: { key:"STACEY", color:"--bds-stacey", u:118, wf:0.90, bw:12, bh:20, hd:9, walk:".42s",
104
+ face:{ hair:"#9a5a32", style:"long", eyes:"round", brows:null, mouth:"smile", glasses:false, woman:true } },
105
+ kevin: { key:"KEVIN", color:"--bds-kevin", u:132, wf:1.00, bw:14, bh:22, hd:12, walk:".5s", roundHead:true,
106
+ face:{ hair:"#33324a", style:"neat", eyes:"round", brows:"stern", mouth:"smirk", glasses:true, woman:false } },
107
+ janet: { key:"JANET", color:"--bds-janet", u:138, wf:1.00, bw:14, bh:23, hd:10, walk:".46s",
108
+ face:{ hair:"#241a30", style:"long", eyes:"round", brows:null, mouth:"smile", glasses:false, woman:true, streak:"var(--bds-neon-magenta)" } },
109
+ derek: { key:"DEREK", color:"--bds-derek", u:148, wf:1.18, bw:18, bh:24, hd:11, walk:"1.1s",
110
+ face:{ hair:"#6f6a52", style:"bald", eyes:"sleepy", brows:"flat", mouth:"flat", glasses:false, woman:false } },
111
+ /* generic grey-suit board member; pass face overrides via opts */
112
+ board: { key:"BOARD", color:"--bds-grey", u:138, wf:1.06, bw:16, bh:23, hd:10, walk:".6s", suit:"#3c3a52",
113
+ face:{ hair:"#4a485e", style:"neat", eyes:"dot", brows:"flat", mouth:"flat", glasses:false, woman:false } },
114
+ /* Kevin's parent — taller, older */
115
+ parent: { key:"PARENT", color:"--bds-kevin", u:158, wf:1.04, bw:16, bh:26, hd:11, walk:".7s", suit:"#6b632e",
116
+ face:{ hair:"#c9c4d8", style:"bald", eyes:"dot", brows:"flat", mouth:"smile", glasses:true, woman:false } },
117
+ };
118
+
119
+ /* ---------- pixel glyphs (box-shadow pixel art) ---------- */
120
+ const GLYPHS = {
121
+ heart: ["·##·##·", "#######", "#######", "·#####·", "··###··", "···#···"],
122
+ star: ["···#···", "··###··", "#######", "·#####·", "··#·#··", "·#···#·"],
123
+ drop: ["··#··", "·###·", "#####", "#####", "·###·"],
124
+ z: ["####", "··#·", "·#··", "####"],
125
+ money: ["·###·", "#·#··", "·###·", "··#·#", "·###·"],
126
+ excl: ["##", "##", "##", "··", "##"],
127
+ flame: ["··#··", "·##··", "·###·", "#####", "·###·"],
128
+ };
129
+
130
+ function pixelGlyph(rows, px, color, glow) {
131
+ if (typeof rows === "string") rows = GLYPHS[rows];
132
+ px = px || 3;
133
+ const shadows = [];
134
+ rows.forEach((row, y) => {
135
+ [...row].forEach((ch, x) => {
136
+ if (ch === "#") shadows.push(`${x * px}px ${y * px}px 0 0 ${color}`);
137
+ });
138
+ });
139
+ const d = document.createElement("i");
140
+ d.className = "pxglyph";
141
+ d.style.cssText = `display:block;position:relative;width:${px}px;height:${px}px;` +
142
+ `margin-right:${(rows[0].length - 1) * px}px;margin-bottom:${(rows.length - 1) * px}px;` +
143
+ `background:transparent;box-shadow:${shadows.join(",")};` +
144
+ (glow ? `filter:drop-shadow(0 0 4px ${color});` : "");
145
+ return d;
146
+ }
147
+ function glyphHTML(name, px, color, extra) {
148
+ const rows = GLYPHS[name];
149
+ const shadows = [];
150
+ rows.forEach((row, y) => [...row].forEach((ch, x) => {
151
+ if (ch === "#") shadows.push(`${x * px}px ${y * px}px 0 0 ${color}`);
152
+ }));
153
+ return `<i class="pxglyph" style="display:block;width:${px}px;height:${px}px;box-shadow:${shadows.join(",")};${extra || ""}"></i>`;
154
+ }
155
+
156
+ /* ---------- front-view chibi ----------
157
+ state: idle|down|up|left|right|crisis|talk
158
+ opts: { eyes ('closed'|'happy'|'heart'|'wide'), mouth ('open'|'frown'|'wavy'|
159
+ 'smile'|'flat'|'worried'|'neutral'|'smirk'), brows, armPose
160
+ ('out'|'palms'|'point'|'open'|'hips'|'behindhead'|'wring'),
161
+ tears, sweat, zzz, heartAbove, blush, tilt (deg), anim (extra classes),
162
+ suit (torso color override), noBubble, scale } */
163
+ function chibi(who, state, opts) {
164
+ opts = opts || {};
165
+ const c = typeof who === "string" ? CHARS[who] : who;
166
+ const u = c.u, wf = c.wf;
167
+ const col = opts.suit || c.suit ? `${opts.suit || c.suit}` : `var(${c.color})`;
168
+ const skin = "var(--bds-white)";
169
+ const pants = "#241a44";
170
+ const F = Object.assign({}, c.face, opts.face || {});
171
+ const headD = Math.round((c.roundHead ? 0.50 : 0.46) * u);
172
+ const torsoW = Math.round(0.40 * u * wf);
173
+ const torsoH = Math.round(0.30 * u);
174
+ const armW = Math.round(0.13 * u), armH = Math.round(0.26 * u);
175
+ const legW = Math.round(0.16 * u), legH = Math.round(0.17 * u), legGap = Math.round(0.05 * u);
176
+ const handD = Math.round(0.13 * u);
177
+
178
+ const torsoBottomY = Math.round(legH * 0.45);
179
+ const torsoTopY = torsoBottomY + torsoH;
180
+ const headBottomY = torsoTopY - Math.round(headD * 0.22);
181
+ const headTopY = headBottomY + headD;
182
+ const H = headTopY + Math.round(0.06 * u);
183
+ const W = Math.round(Math.max(headD, torsoW + armW * 1.4) + Math.round(0.18 * u));
184
+ const cx = W / 2;
185
+ const headLeft = cx - headD / 2;
186
+
187
+ const isBack = state === "up";
188
+ const side = (state === "left" || state === "right") ? state : null;
189
+
190
+ const px = (fx) => headLeft + headD * fx;
191
+ const py = (fy) => headBottomY + headD * (1 - fy);
192
+
193
+ const el = document.createElement("div");
194
+ el.className = "chibi";
195
+ el.style.width = W + "px"; el.style.height = H + "px";
196
+ if (opts.scale) { el.style.transform = `scale(${opts.scale})`; }
197
+ if (opts.tilt) { el.style.transform = (el.style.transform || "") + ` rotate(${opts.tilt}deg)`; }
198
+ let html = "";
199
+
200
+ /* long hair back panels */
201
+ if (F.style === "long") {
202
+ const hw = Math.round(headD * 0.26), hh = (headTopY - torsoBottomY) - Math.round(0.02 * u);
203
+ const hbot = torsoBottomY + Math.round(0.02 * u);
204
+ const z = isBack ? 7 : 3;
205
+ html += `<div class="hairpc" style="width:${hw}px;height:${hh}px;left:${headLeft - Math.round(0.01*u)}px;bottom:${hbot}px;background:${F.hair};border-radius:60% 40% 45% 55%;z-index:${z};"></div>`;
206
+ html += `<div class="hairpc" style="width:${hw}px;height:${hh}px;left:${headLeft + headD - hw + Math.round(0.01*u)}px;bottom:${hbot}px;background:${F.hair};border-radius:40% 60% 55% 45%;z-index:${z};"></div>`;
207
+ if (F.streak) html += `<div class="hairpc" style="width:${Math.round(hw*0.4)}px;height:${Math.round(hh*0.8)}px;left:${headLeft + headD - hw + Math.round(0.01*u)}px;bottom:${hbot}px;background:${F.streak};border:none;border-radius:40% 60% 55% 45%;z-index:${z+1};"></div>`;
208
+ }
209
+
210
+ /* legs */
211
+ html += `<div class="part leg l" style="width:${legW}px;height:${legH}px;left:${cx - legGap/2 - legW}px;bottom:0;background:${pants};"></div>`;
212
+ html += `<div class="part leg r" style="width:${legW}px;height:${legH}px;left:${cx + legGap/2}px;bottom:0;background:${pants};"></div>`;
213
+
214
+ /* arms */
215
+ const armBottom = torsoBottomY + Math.round(0.03 * u);
216
+ const pose = opts.armPose;
217
+ function armPair(rotL, rotR, raiseL, raiseR) {
218
+ const lx = cx - torsoW/2 - armW + Math.round(0.03*u);
219
+ const rx = cx + torsoW/2 - Math.round(0.03*u);
220
+ /* hand nests INSIDE the arm so it inherits the arm's rotation —
221
+ a sibling hand with a linear offset drifts off-body at high rotations */
222
+ const hand = `<div class="part hand" style="width:${handD}px;height:${handD}px;left:50%;margin-left:${-Math.round(handD/2) - 3}px;bottom:${-Math.round(handD * 0.4)}px;background:${skin};"></div>`;
223
+ html += `<div class="part arm l" style="width:${armW}px;height:${armH}px;left:${lx}px;bottom:${armBottom + (raiseL||0)}px;background:${col};transform:rotate(${rotL}deg);">${hand}</div>`;
224
+ html += `<div class="part arm r gesture" style="width:${armW}px;height:${armH}px;left:${rx}px;bottom:${armBottom + (raiseR||0)}px;background:${col};transform:rotate(${rotR}deg);">${hand}</div>`;
225
+ }
226
+ if (side) {
227
+ const fx = side === "right" ? 1 : -1;
228
+ html += `<div class="part arm gesture" style="width:${armW}px;height:${armH}px;left:${cx - armW/2 + fx*Math.round(0.10*u)}px;bottom:${armBottom}px;background:${col};transform:rotate(${fx*22}deg);"></div>`;
229
+ } else if (pose === "out") armPair(8, -70, 0, Math.round(0.06*u));
230
+ else if (pose === "open") armPair(46, -46, Math.round(0.04*u), Math.round(0.04*u));
231
+ else if (pose === "palms") armPair(58, -58, Math.round(0.07*u), Math.round(0.07*u));
232
+ else if (pose === "point") armPair(6, -88, 0, Math.round(0.10*u));
233
+ else if (pose === "hips") armPair(-34, 34, 0, 0);
234
+ else if (pose === "behindhead") armPair(4, -160, 0, Math.round(0.16*u));
235
+ else if (pose === "wring") armPair(22, -22, 0, 0);
236
+ else if (pose === "crossed") {
237
+ html += `<div class="part arm" style="width:${torsoW*0.92}px;height:${Math.round(armH*0.5)}px;left:${cx - torsoW*0.46}px;bottom:${armBottom + Math.round(0.05*u)}px;background:${col};border-radius:30%;z-index:4;"></div>`;
238
+ }
239
+ else armPair(0, 0, 0, 0);
240
+
241
+ /* torso */
242
+ html += `<div class="part torso" style="width:${torsoW}px;height:${torsoH}px;left:${cx - torsoW/2}px;bottom:${torsoBottomY}px;background:${col};"></div>`;
243
+ if (c.suit || opts.suit) { /* tie pixel for suits */
244
+ html += `<div style="width:${Math.round(0.04*u)}px;height:${Math.round(torsoH*0.6)}px;left:${cx - Math.round(0.02*u)}px;bottom:${torsoTopY - Math.round(torsoH*0.62)}px;background:${DARK};z-index:5;"></div>`;
245
+ }
246
+ if (!isBack && !side) {
247
+ const nw = Math.round(headD*0.34), nh = Math.round(0.05*u);
248
+ html += `<div class="collar" style="width:${nw}px;height:${nh}px;left:${cx - nw/2}px;bottom:${torsoTopY - nh - 1}px;background:rgba(0,0,0,.28);border-radius:0 0 60% 60%;"></div>`;
249
+ }
250
+
251
+ /* head */
252
+ html += `<div class="part head" style="width:${headD}px;height:${headD}px;left:${headLeft}px;bottom:${headBottomY}px;background:${skin};${isBack ? "filter:brightness(.92);" : ""}"></div>`;
253
+
254
+ /* hair cap */
255
+ const cap = (w, h, by, radius) =>
256
+ `<div class="hairpc" style="width:${w}px;height:${h}px;left:${cx - w/2}px;bottom:${by}px;background:${F.hair};border-radius:${radius};"></div>`;
257
+ if (F.style === "bald") html += cap(Math.round(headD*0.66), Math.round(headD*0.20), headTopY - Math.round(headD*0.22), "50% 50% 14% 14%");
258
+ else if (F.style === "messy")html += cap(Math.round(headD*0.94), Math.round(headD*0.46), headTopY - Math.round(headD*0.42), "60% 50% 35% 30% / 70% 65% 30% 30%");
259
+ else if (F.style === "neat") html += cap(Math.round(headD*0.92), Math.round(headD*0.40), headTopY - Math.round(headD*0.36), "55% 55% 20% 40%");
260
+ else if (F.style === "long") html += cap(Math.round(headD*0.98), Math.round(headD*0.46), headTopY - Math.round(headD*0.40), "55% 55% 40% 40%");
261
+ else html += cap(Math.round(headD*0.9), Math.round(headD*0.40), headTopY - Math.round(headD*0.34), "55% 55% 30% 30%");
262
+
263
+ /* face */
264
+ if (!isBack && !opts.hideFace) {
265
+ const eyeBase = Math.max(4, Math.round(0.085 * u));
266
+ let exL = 0.34, exR = 0.66;
267
+ if (side === "right") { exL = 0.50; exR = 0.74; }
268
+ if (side === "left") { exL = 0.26; exR = 0.50; }
269
+ const eyeY = 0.50;
270
+ const eyeStyle = opts.eyes || F.eyes;
271
+
272
+ const drawEye = (fx) => {
273
+ if (eyeStyle === "closed" || eyeStyle === "sleepy") {
274
+ const w = Math.round(eyeBase*1.4), h = Math.max(2, Math.round(eyeBase*0.42));
275
+ return `<div class="eye" style="width:${w}px;height:${h}px;border-radius:40%;left:${px(fx)-w/2}px;bottom:${py(eyeY)-h/2}px;"></div>`;
276
+ }
277
+ if (eyeStyle === "happy") {
278
+ const w = Math.round(eyeBase*1.5), h = Math.round(eyeBase*0.9);
279
+ return `<div class="eye" style="width:${w}px;height:${h}px;background:transparent;border:3px solid ${DARK};border-bottom:none;border-radius:80% 80% 0 0;left:${px(fx)-w/2}px;bottom:${py(eyeY)-h/3}px;"></div>`;
280
+ }
281
+ if (eyeStyle === "heart") {
282
+ return `<div class="eye" style="background:transparent;left:${px(fx)-9}px;bottom:${py(eyeY)-6}px;">${glyphHTML("heart", 3, "var(--bds-neon-magenta)", "filter:drop-shadow(0 0 4px var(--bds-neon-magenta));")}</div>`;
283
+ }
284
+ if (eyeStyle === "wide") {
285
+ const d = Math.round(eyeBase*1.5);
286
+ return `<div class="eye" style="width:${d}px;height:${d}px;background:${skin};border:3px solid ${DARK};left:${px(fx)-d/2}px;bottom:${py(eyeY)-d/2}px;"></div>`;
287
+ }
288
+ const d = eyeStyle === "big" ? Math.round(eyeBase*1.25) : eyeBase;
289
+ return `<div class="eye" style="width:${d}px;height:${d}px;left:${px(fx)-d/2}px;bottom:${py(eyeY)-d/2}px;"></div>`;
290
+ };
291
+ html += drawEye(exL) + drawEye(exR);
292
+
293
+ if (F.woman && eyeStyle !== "heart") {
294
+ const lw = Math.max(3, Math.round(eyeBase*0.7)), lh = Math.max(2, Math.round(0.022*u));
295
+ html += `<div class="lash" style="width:${lw}px;height:${lh}px;left:${px(exL)-eyeBase*0.7}px;bottom:${py(eyeY)+eyeBase*0.45}px;transform:rotate(-24deg);border-radius:2px;"></div>`;
296
+ html += `<div class="lash" style="width:${lw}px;height:${lh}px;left:${px(exR)+eyeBase*0.0}px;bottom:${py(eyeY)+eyeBase*0.45}px;transform:rotate(24deg);border-radius:2px;"></div>`;
297
+ }
298
+
299
+ const brows = opts.brows !== undefined ? opts.brows : F.brows;
300
+ if (brows) {
301
+ const bw = Math.round(headD*0.16), bh = Math.max(2, Math.round(0.028*u));
302
+ const browY = 0.34;
303
+ let rotL = 0, rotR = 0;
304
+ if (brows === "worried") { rotL = 16; rotR = -16; }
305
+ else if (brows === "stern") { rotL = -15; rotR = 15; }
306
+ html += `<div class="brow" style="width:${bw}px;height:${bh}px;background:${F.hair};left:${px(exL)-bw/2}px;bottom:${py(browY)}px;transform:rotate(${rotL}deg);"></div>`;
307
+ html += `<div class="brow" style="width:${bw}px;height:${bh}px;background:${F.hair};left:${px(exR)-bw/2}px;bottom:${py(browY)}px;transform:rotate(${rotR}deg);"></div>`;
308
+ }
309
+
310
+ if (F.glasses) {
311
+ const gd = Math.round(eyeBase*2.0);
312
+ html += `<div class="glasses" style="width:${gd}px;height:${Math.round(gd*0.86)}px;left:${px(exL)-gd/2}px;bottom:${py(eyeY)-gd*0.43}px;"></div>`;
313
+ html += `<div class="glasses" style="width:${gd}px;height:${Math.round(gd*0.86)}px;left:${px(exR)-gd/2}px;bottom:${py(eyeY)-gd*0.43}px;"></div>`;
314
+ html += `<div class="bridge" style="width:${px(exR)-px(exL)-gd}px;height:3px;left:${px(exL)+gd/2}px;bottom:${py(eyeY)}px;"></div>`;
315
+ }
316
+
317
+ /* mouth */
318
+ const mcx = px(0.5);
319
+ const mouth = opts.mouth || (state === "talk" ? "open" : F.mouth);
320
+ const my = 0.72;
321
+ if (mouth === "open") {
322
+ const mw = Math.round(headD*0.22), mh = Math.round(headD*0.16);
323
+ html += `<div class="mouth" style="width:${mw}px;height:${mh}px;left:${mcx-mw/2}px;bottom:${py(0.74)-mh/2}px;background:${DARK};border-radius:45%;transform-origin:center;"></div>`;
324
+ } else if (mouth === "smile") {
325
+ const mw = Math.round(headD*0.24), mh = Math.round(headD*0.12);
326
+ html += `<div class="mouth" style="width:${mw}px;height:${mh}px;left:${mcx-mw/2}px;bottom:${py(my)-mh}px;border:3px solid ${DARK};border-top:none;background:transparent;border-radius:0 0 80% 80%;"></div>`;
327
+ } else if (mouth === "frown") {
328
+ const mw = Math.round(headD*0.24), mh = Math.round(headD*0.12);
329
+ html += `<div class="mouth" style="width:${mw}px;height:${mh}px;left:${mcx-mw/2}px;bottom:${py(my)-mh/2}px;border:3px solid ${DARK};border-bottom:none;background:transparent;border-radius:80% 80% 0 0;"></div>`;
330
+ } else if (mouth === "worried") {
331
+ const mw = Math.round(headD*0.16), mh = Math.round(headD*0.13);
332
+ html += `<div class="mouth" style="width:${mw}px;height:${mh}px;left:${mcx-mw/2}px;bottom:${py(my)-mh/2}px;background:${DARK};border-radius:48%;"></div>`;
333
+ } else if (mouth === "smirk") {
334
+ const mw = Math.round(headD*0.22), mh = Math.max(3, Math.round(0.03*u));
335
+ html += `<div class="mouth" style="width:${mw}px;height:${mh}px;left:${mcx-mw/2+Math.round(headD*0.04)}px;bottom:${py(my)}px;background:${DARK};border-radius:3px;transform:rotate(-12deg);"></div>`;
336
+ } else if (mouth === "wavy") {
337
+ const mw = Math.round(headD*0.07);
338
+ for (let i = 0; i < 3; i++) {
339
+ html += `<div class="mouth" style="width:${mw}px;height:3px;left:${mcx-mw*1.5+i*mw}px;bottom:${py(my)+(i%2?2:0)}px;background:${DARK};"></div>`;
340
+ }
341
+ } else if (mouth === "flat") {
342
+ const mw = Math.round(headD*0.22), mh = Math.max(2, Math.round(0.025*u));
343
+ html += `<div class="mouth" style="width:${mw}px;height:${mh}px;left:${mcx-mw/2}px;bottom:${py(my)}px;background:${DARK};border-radius:2px;"></div>`;
344
+ } else {
345
+ const mw = Math.round(headD*0.12), mh = Math.max(2, Math.round(0.025*u));
346
+ html += `<div class="mouth" style="width:${mw}px;height:${mh}px;left:${mcx-mw/2}px;bottom:${py(my)}px;background:${DARK};border-radius:2px;"></div>`;
347
+ }
348
+
349
+ if (opts.blush) {
350
+ const bd = Math.round(headD*0.14);
351
+ html += `<div class="blush" style="width:${bd}px;height:${Math.round(bd*0.55)}px;left:${px(0.22)}px;bottom:${py(0.66)}px;background:var(--bds-brad);"></div>`;
352
+ html += `<div class="blush" style="width:${bd}px;height:${Math.round(bd*0.55)}px;left:${px(0.70)}px;bottom:${py(0.66)}px;background:var(--bds-brad);"></div>`;
353
+ }
354
+ if (opts.sweat || (F.sweat && (state === "crisis"))) {
355
+ const sw = Math.round(0.05*u), sh = Math.round(0.07*u);
356
+ html += `<div class="sweat" style="width:${sw}px;height:${sh}px;left:${px(0.84)}px;bottom:${py(0.30)}px;"></div>`;
357
+ }
358
+ if (opts.tears) {
359
+ html += `<div class="tearpx" style="position:absolute;left:${px(0.30)}px;bottom:${py(0.62)}px;z-index:9;">${glyphHTML("drop", 2, "var(--bds-derek)", "filter:drop-shadow(0 0 3px var(--bds-derek));")}</div>`;
360
+ html += `<div class="tearpx" style="position:absolute;left:${px(0.66)}px;bottom:${py(0.64)}px;z-index:9;">${glyphHTML("drop", 2, "var(--bds-derek)", "filter:drop-shadow(0 0 3px var(--bds-derek));")}</div>`;
361
+ }
362
+ }
363
+
364
+ /* above-head extras */
365
+ if (opts.heartAbove) {
366
+ html += `<div style="position:absolute;left:${cx + Math.round(headD*0.18)}px;bottom:${headTopY + 6}px;z-index:10;">${glyphHTML("heart", 3, "var(--bds-neon-magenta)", "filter:drop-shadow(0 0 5px var(--bds-neon-magenta));")}</div>`;
367
+ }
368
+ if (opts.zzz) {
369
+ for (let i = 0; i < 3; i++) {
370
+ html += `<div class="zpx" style="position:absolute;left:${cx + Math.round(headD*0.34) + i*10}px;bottom:${headTopY - 6 + i*9}px;z-index:10;animation-delay:${i*0.8}s;">${glyphHTML("z", 2, "var(--bds-grey)", "")}</div>`;
371
+ }
372
+ }
373
+ if (opts.sweatDropAbove) {
374
+ html += `<div style="position:absolute;left:${px(0.86)}px;bottom:${headTopY - 4}px;z-index:10;">${glyphHTML("drop", 3, "var(--bds-stacey)", "filter:drop-shadow(0 0 4px var(--bds-stacey));")}</div>`;
375
+ }
376
+ if (state === "crisis" && !opts.noBubble) {
377
+ html += `<div style="position:absolute;left:${cx + Math.round(headD*0.20)}px;bottom:${headTopY - 2}px;z-index:10;width:28px;height:28px;border-radius:8px;background:var(--bds-white);border:3px solid ${DARK};display:flex;align-items:center;justify-content:center;font-family:var(--font-display);font-size:13px;color:var(--bds-danger);box-shadow:0 0 12px rgba(255,90,60,.6);">!</div>`;
378
+ }
379
+
380
+ el.innerHTML = html;
381
+
382
+ /* animation classes */
383
+ if (opts.anim) opts.anim.split(" ").forEach(cl => cl && el.classList.add(cl));
384
+ else if (state === "idle") {
385
+ if (c.key === "DEREK") { /* still */ }
386
+ else el.classList.add("a-idle");
387
+ } else if (["down","up","left","right"].includes(state)) {
388
+ el.classList.add("a-walk","a-bob");
389
+ el.style.setProperty("--spd", c.walk);
390
+ } else if (state === "crisis") {
391
+ el.classList.add(c.key === "DEREK" ? "a-rise" : "a-crisis");
392
+ } else if (state === "talk") {
393
+ el.classList.add("a-mouth");
394
+ }
395
+ return el;
396
+ }
397
+
398
+ /* ---------- compact naturalistic 3/4 person (28x34 box) ----------
399
+ Shared body for floor sprites. Front-facing (or back when facing up),
400
+ skin + hair + office shirt — matches the warmlight Office Floor cast.
401
+ opts: { skin, hair, shirt, pants, back, style, glasses, streak, sweat,
402
+ eyes ('sleepy'|'big'|...), mouth ('smile'|'smirk'|'worried'|...) } */
403
+ function personHTML(o, ink) {
404
+ const skin = o.skin || "#f0c8a4", hair = o.hair || "#3a2a18",
405
+ shirt = o.shirt || "var(--bds-white)", pants = o.pants || "#2c2b3a",
406
+ style = o.style || "short", back = o.back;
407
+ const R = (x, y, w, h, bg, ex) => `<i style="left:${x}px;top:${y}px;width:${w}px;height:${h}px;background:${bg};${ex || ""}"></i>`;
408
+ const RC = (cls, x, y, w, h, bg, ex) => `<i class="${cls}" style="left:${x}px;top:${y}px;width:${w}px;height:${h}px;background:${bg};${ex || ""}"></i>`;
409
+ const O = `box-shadow:0 0 0 2px ${ink};`;
410
+ let h = "";
411
+ // legs + shoes — each leg shares its class with its shoe so they step as one
412
+ h += RC("fl-leg-l", 7, 25, 5, 8, pants, O) + RC("fl-leg-r", 14, 25, 5, 8, pants, O);
413
+ h += RC("fl-leg-l", 6, 31, 7, 3, "#26242f") + RC("fl-leg-r", 13, 31, 7, 3, "#26242f");
414
+ // arms + hands — each arm shares its class with its hand so they swing as one
415
+ h += RC("fl-arm-l", 0, 14, 4, 10, shirt, O) + RC("fl-arm-r", 22, 14, 4, 10, shirt, O);
416
+ h += RC("fl-arm-l", 0, 22, 4, 3, skin) + RC("fl-arm-r", 22, 22, 4, 3, skin);
417
+ // torso
418
+ h += R(4, 13, 18, 14, shirt, O + "border-radius:5px 5px 2px 2px;");
419
+ if (o.tie && !back) h += R(12, 14, 3, 10, o.tie);
420
+ h += R(11, 11, 4, 4, skin); // neck
421
+ // head
422
+ h += R(5, 0, 16, 14, skin, O + "border-radius:46% 46% 44% 44%;z-index:2;");
423
+ // hair
424
+ if (style === "bald") h += R(7, -1, 12, 5, hair, O + "border-radius:50% 50% 12% 12%;z-index:3;");
425
+ else if (style === "long") {
426
+ h += R(3, 1, 4, 15, hair, O + "border-radius:50% 20% 30% 60%;z-index:1;") + R(19, 1, 4, 15, hair, O + "border-radius:20% 50% 60% 30%;z-index:1;");
427
+ h += R(4, -2, 18, 8, hair, O + "border-radius:55% 55% 30% 30%;z-index:3;");
428
+ if (o.streak) h += R(18, 0, 4, 12, o.streak, "z-index:4;border-radius:30% 50% 60% 30%;");
429
+ } else if (style === "messy") h += R(3, -3, 20, 9, hair, O + "border-radius:60% 50% 40% 40% / 70% 65% 30% 30%;z-index:3;");
430
+ else h += R(4, -2, 18, 8, hair, O + "border-radius:55% 55% 25% 35%;z-index:3;");
431
+ if (back) {
432
+ // back of head — fill hair lower, no face
433
+ h += R(5, 2, 16, 10, hair, "z-index:3;border-radius:40% 40% 46% 46%;");
434
+ } else {
435
+ // eyes — sleepy reads as half-closed lines, big for the anxious ones
436
+ if (o.eyes === "sleepy") {
437
+ h += R(9, 7, 3, 1, ink, "z-index:4;border-radius:1px;") + R(14, 7, 3, 1, ink, "z-index:4;border-radius:1px;");
438
+ } else {
439
+ const eh = o.eyes === "big" ? 4 : 3;
440
+ h += R(9, 5, 2, eh, ink, "z-index:4;border-radius:1px;") + R(15, 5, 2, eh, ink, "z-index:4;border-radius:1px;");
441
+ }
442
+ if (o.glasses) {
443
+ h += R(7, 4, 5, 5, "transparent", "z-index:5;border:2px solid " + ink + ";border-radius:2px;") + R(14, 4, 5, 5, "transparent", "z-index:5;border:2px solid " + ink + ";border-radius:2px;") + R(12, 6, 2, 2, ink, "z-index:5;");
444
+ }
445
+ // mouth — a few legible expressions that still read at 16px
446
+ const mth = o.mouth;
447
+ if (mth === "smile") h += R(10, 10, 6, 3, "transparent", "z-index:4;border:2px solid rgba(120,40,30,.6);border-top:none;border-radius:0 0 70% 70%;");
448
+ else if (mth === "smirk") h += R(11, 10, 5, 2, "rgba(120,40,30,.6)", "z-index:4;border-radius:2px;transform:rotate(-13deg);");
449
+ else if (mth === "worried") h += R(11, 9, 3, 3, "rgba(120,40,30,.55)", "z-index:4;border-radius:50%;");
450
+ else h += R(11, 10, 4, 2, "rgba(120,40,30,.5)", "z-index:4;border-radius:2px;");
451
+ if (o.sweat) h += R(18, 3, 3, 4, "#7fc6e8", "z-index:6;border-radius:60% 60% 50% 50%;box-shadow:0 0 4px #7fc6e8;");
452
+ }
453
+ return h;
454
+ }
455
+
456
+ /* ---------- top-down floor sprite (warmlight 3/4 person) ----------
457
+ Back-compatible API. `color` becomes the shirt; identity is kept via
458
+ the shirt + hair. opts: { color, hair, skin, pose, dim, facing, scale,
459
+ still, glasses, streak, style, sweat } */
460
+ function topSprite(opts) {
461
+ opts = opts || {};
462
+ // pass who:'brad' to inherit that character's colour + face traits; explicit
463
+ // color/hair/style/etc. still override (back-compatible).
464
+ const c = opts.who && CHARS[opts.who] ? CHARS[opts.who] : null;
465
+ const f = c ? c.face : {};
466
+ const el = document.createElement("div");
467
+ el.className = "bds-top-sprite";
468
+ el.style.width = "26px"; el.style.height = "34px";
469
+ const anims = { idle: "a-top-idle", walk: "a-top-walk", shake: "a-top-shake" };
470
+ const pose = opts.pose;
471
+ if (!opts.still && anims[pose]) el.classList.add(anims[pose]);
472
+ else if (!opts.still && (pose == null || pose === "idle")) el.classList.add("a-top-idle");
473
+ // per-character walk cadence (brad frantic, derek plodding)
474
+ el.style.setProperty("--spd", opts.walk || (c && c.walk) || ".5s");
475
+ let tf = "";
476
+ if (pose === "lean") tf = "rotate(-8deg)";
477
+ else if (pose === "slump") tf = "rotate(6deg) translateY(3px)";
478
+ if (opts.scale) tf += ` scale(${opts.scale})`;
479
+ if (tf) { el.style.transform = tf; el.style.transformOrigin = "50% 100%"; }
480
+ if (opts.dim) el.style.filter = "brightness(.6) saturate(.7)";
481
+ el.innerHTML = personHTML({
482
+ skin: opts.skin,
483
+ hair: opts.hair || f.hair,
484
+ shirt: opts.color || (c ? `var(${c.color})` : "var(--bds-white)"),
485
+ pants: opts.pants, back: opts.facing === "up",
486
+ style: opts.style || f.style,
487
+ glasses: opts.glasses != null ? opts.glasses : f.glasses,
488
+ streak: opts.streak || f.streak,
489
+ eyes: opts.eyes || f.eyes,
490
+ mouth: opts.mouth || f.mouth,
491
+ sweat: opts.sweat, // sweat is a transient state, not identity — stays explicit
492
+ tie: opts.tie,
493
+ }, "var(--bds-void)");
494
+ return el;
495
+ }
496
+
497
+ /* top-down desk with monitor */
498
+ function desk(opts) {
499
+ opts = opts || {};
500
+ const el = document.createElement("div");
501
+ el.style.cssText = "position:absolute;width:46px;height:22px;left:-23px;top:14px;background:#5a4326;border:2px solid #3a2b16;z-index:4;box-shadow:inset 0 3px 0 #6e542f, inset 0 -3px 0 #3f2f19;";
502
+ const mon = document.createElement("div");
503
+ mon.style.cssText = `position:absolute;left:50%;top:-9px;transform:translateX(-50%);width:18px;height:12px;background:${opts.dark ? "#0a0c14" : "#10131f"};border:2px solid #2a2f44;${opts.dark ? "" : "box-shadow:0 0 6px rgba(80,140,255,.35);"}`;
504
+ el.appendChild(mon);
505
+ return el;
506
+ }
507
+
508
+ /* name tag under a floor sprite */
509
+ function tag(text, color, opts) {
510
+ opts = opts || {};
511
+ const el = document.createElement("div");
512
+ el.style.cssText = `position:absolute;left:50%;top:${opts.top != null ? opts.top : 34}px;transform:translateX(-50%);font-family:var(--font-display);font-size:6px;letter-spacing:1px;color:${color};text-shadow:${opts.glow === false ? "1px 1px 0 var(--bds-void)" : `1px 1px 0 var(--bds-void), 0 0 6px ${color}`};white-space:nowrap;z-index:5;`;
513
+ el.textContent = text;
514
+ return el;
515
+ }
516
+
517
+ /* zero-size anchored station */
518
+ function station(x, y) {
519
+ const el = document.createElement("div");
520
+ el.style.cssText = `position:absolute;left:${x}px;top:${y}px;width:0;height:0;z-index:5;`;
521
+ for (let i = 2; i < arguments.length; i++) if (arguments[i]) el.appendChild(arguments[i]);
522
+ return el;
523
+ }
524
+
525
+ window.BDSChibi = { CHARS, chibi, topSprite, personHTML, desk, tag, station, pixelGlyph, glyphHTML, GLYPHS };
526
+ })();
static/js/comic.js ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // comic.js — centered comic-panel overlay for crisis beats.
2
+ //
3
+ // Shows a wordless FLUX illustration with a UI-rendered caption above it
4
+ // (FLUX renders garbled text, so all words live in the crisp caption, not the
5
+ // picture). When there is no image (FLUX unavailable / failed / timed out / not
6
+ // yet ready), it does NOTHING and calls onDone() straight away — the fallback
7
+ // is simply no overlay, and the normal dialogue opens as usual.
8
+ import { G } from "./state.js";
9
+ import { sfx } from "./audio.js";
10
+
11
+ let dismiss = null; // active dismiss handler while a comic is showing
12
+
13
+ function escapeHtml(s) {
14
+ return String(s).replace(/[&<>"']/g, (c) => ({
15
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
16
+ }
17
+
18
+ export function isOpen() {
19
+ const el = G.els.comic;
20
+ return !!(el && !el.classList.contains("hidden"));
21
+ }
22
+
23
+ // imageB64: a base64 PNG string, or null/"" → skip the overlay entirely.
24
+ // caption: optional text drawn above the picture.
25
+ export function showComic(imageB64, caption, onDone) {
26
+ if (!imageB64) { onDone && onDone(); return; }
27
+ const el = G.els.comic;
28
+ const cap = caption && caption.trim()
29
+ ? `<div class="comic-caption">${escapeHtml(caption.trim())}</div>` : "";
30
+ el.innerHTML = `
31
+ <div class="comic-frame">
32
+ ${cap}
33
+ <img class="comic-img" alt="" src="data:image/png;base64,${imageB64}">
34
+ <div class="comic-hint">CLICK or SPACE &#9654;</div>
35
+ </div>`;
36
+ el.classList.remove("hidden");
37
+ requestAnimationFrame(() => el.classList.add("comic-in"));
38
+ sfx.comic();
39
+
40
+ let done = false;
41
+ const timer = setTimeout(() => dismiss && dismiss(), 7000); // auto-advance
42
+ dismiss = () => {
43
+ if (done) return;
44
+ done = true;
45
+ clearTimeout(timer);
46
+ el.classList.remove("comic-in");
47
+ el.classList.add("hidden");
48
+ el.innerHTML = "";
49
+ el.removeEventListener("click", dismiss);
50
+ dismiss = null;
51
+ onDone && onDone();
52
+ };
53
+ el.addEventListener("click", dismiss);
54
+ }
55
+
56
+ // called by the SPACE/interact handler in main.js while a comic is showing
57
+ export function dismissComic() {
58
+ if (dismiss) { dismiss(); return true; }
59
+ return false;
60
+ }
static/js/dialogue.js ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // dialogue box: crisis lifecycle + presentation variant (UI_UX.md §5 §6)
2
+ import { G } from "./state.js";
3
+ import { charColor, crisisPortrait, outcomePortrait } from "./sprites.js";
4
+ import { escapeHtml } from "./papertrail.js";
5
+ import { setTyping } from "./input.js";
6
+ import { sfx } from "./audio.js";
7
+
8
+ const NPC_TITLES = {
9
+ brad: "SENIOR ACCOUNT EXECUTIVE", stacey: "ACCOUNT MANAGER",
10
+ kevin: "DATA & INSIGHTS LEAD", janet: "HEAD OF MARKETING",
11
+ derek: "SENIOR STRATEGIC CONSULTANT",
12
+ };
13
+
14
+ let current = null; // {mode, onRespond, stage}
15
+
16
+ export function stage() { return current ? current.stage : null; }
17
+
18
+ // ---- typewriter: dialog-game text reveal, click anywhere to complete ----
19
+ let typer = null;
20
+
21
+ function finishTyping() {
22
+ if (!typer) return;
23
+ clearInterval(typer.id);
24
+ typer.el.textContent = typer.text;
25
+ typer = null;
26
+ }
27
+
28
+ function typewrite(el, text) {
29
+ finishTyping();
30
+ if (!el) return;
31
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
32
+ el.textContent = text;
33
+ return;
34
+ }
35
+ // 2 chars per tick at ~60fps ≈ 120 chars/s; long text capped at ~3.5s
36
+ const step = Math.max(2, Math.ceil(text.length / 200));
37
+ let i = 0;
38
+ el.textContent = "";
39
+ const id = setInterval(() => {
40
+ i += step;
41
+ el.textContent = text.slice(0, i);
42
+ if (i < text.length) sfx.type(); // soft wooden tick (rate-limited inside)
43
+ else { clearInterval(id); typer = null; }
44
+ }, 16);
45
+ typer = { id, el, text };
46
+ }
47
+
48
+ // clicking the box completes the reveal (buttons still work normally)
49
+ document.addEventListener("click", (e) => {
50
+ if (typer && e.target.closest("#dialogue")) finishTyping();
51
+ });
52
+
53
+ function box() { return G.els.dialogue; }
54
+
55
+ function show(html, frameClass = "") {
56
+ const el = box();
57
+ el.className = frameClass;
58
+ el.innerHTML = html;
59
+ el.classList.remove("hidden", "closing");
60
+ G.dialogueOpen = true;
61
+ }
62
+
63
+ export function close() {
64
+ const el = box();
65
+ el.classList.add("closing");
66
+ setTyping(false);
67
+ sfx.close();
68
+ G.dialogueOpen = false;
69
+ current = null;
70
+ setTimeout(() => { el.classList.add("hidden"); el.classList.remove("closing"); }, 240);
71
+ }
72
+
73
+ function bindInput(onRespond) {
74
+ const input = box().querySelector(".dlg-input");
75
+ const send = box().querySelector(".btn-send");
76
+ if (!input) return;
77
+ input.addEventListener("focus", () => setTyping(true));
78
+ input.addEventListener("blur", () => setTyping(false));
79
+ input.addEventListener("keydown", (e) => {
80
+ if (e.key === "Enter" && input.value.trim()) {
81
+ e.preventDefault(); onRespond("custom", input.value.trim());
82
+ }
83
+ e.stopPropagation();
84
+ });
85
+ if (send) send.addEventListener("click", () => {
86
+ if (input.value.trim()) onRespond("custom", input.value.trim());
87
+ });
88
+ setTimeout(() => input.focus(), 350);
89
+ }
90
+
91
+ function inputRowHtml(placeholder) {
92
+ return `<div class="dlg-inputrow">
93
+ <input class="dlg-input" maxlength="400"
94
+ placeholder="${placeholder || "or type your own response..."}">
95
+ <button class="btn-send">SEND &#9654;</button>
96
+ </div>`;
97
+ }
98
+
99
+ // ------------------------------------------------------------------ crisis
100
+
101
+ export function openCrisis(event, onRespond) {
102
+ current = { mode: "crisis", onRespond, stage: "question" };
103
+ const npcId = event.affected_npc !== "player" ? event.affected_npc : null;
104
+ const color = npcId ? charColor(npcId) : "var(--bds-neon-cyan)";
105
+ const isBribe = event.special === "bribery";
106
+ const isRomance = event.special === "romance";
107
+ const labels = isBribe ? ["THE TEMPTING ONE", "THE PRINCIPLED ONE"]
108
+ : isRomance ? ["‹♥› LEAN IN", "KEEP IT PROFESSIONAL"]
109
+ : ["THE STUPID ONE", "THE CURSED ONE"];
110
+ const header = npcId
111
+ ? `<div class="dlg-head">
112
+ <div class="dlg-sprite" data-portrait="${npcId}"></div>
113
+ <div><span class="dlg-name">${npcId.toUpperCase()}</span>
114
+ <span class="dlg-title">${NPC_TITLES[npcId]}</span></div>
115
+ </div>`
116
+ : `<div class="dlg-head"><div>
117
+ <span class="dlg-name" style="color:${color}">&gt; INCOMING</span>
118
+ <span class="dlg-title">VELOURA INTERNAL SYSTEMS</span></div></div>`;
119
+
120
+ show(`
121
+ ${header}
122
+ <div class="dlg-headline">${escapeHtml(event.headline).toUpperCase()}</div>
123
+ <div class="dlg-body" data-typed></div>
124
+ <div class="dlg-options">
125
+ <button class="dlg-option a"><span class="opt-label">[1] ${labels[0]}</span>
126
+ ${escapeHtml(event.option_a)}</button>
127
+ <button class="dlg-option b"><span class="opt-label">[2] ${labels[1]}</span>
128
+ ${escapeHtml(event.option_b)}</button>
129
+ </div>
130
+ <div class="dlg-urgency">! ${escapeHtml(event.urgency)}</div>
131
+ ${inputRowHtml(isBribe ? "or make a counter-offer..."
132
+ : isRomance ? "...or say it in your own words" : "")}
133
+ `);
134
+ box().style.setProperty("--npc-color", color);
135
+ sfx.open();
136
+
137
+ if (npcId) {
138
+ const slot = box().querySelector("[data-portrait]");
139
+ slot.appendChild(crisisPortrait(npcId)); // the designed chibi, in character
140
+ }
141
+ typewrite(box().querySelector("[data-typed]"), event.intro);
142
+ box().querySelector(".dlg-option.a").addEventListener("click",
143
+ () => onRespond("option_a", ""));
144
+ box().querySelector(".dlg-option.b").addEventListener("click",
145
+ () => onRespond("option_b", ""));
146
+ bindInput(onRespond);
147
+ }
148
+
149
+ export function chooseOption(which) {
150
+ // keyboard 1/2 shortcut routed from main.js
151
+ if (!current || !G.dialogueOpen) return;
152
+ const btn = box().querySelector(which === "a" ? ".dlg-option.a" : ".dlg-option.b");
153
+ if (btn) btn.click();
154
+ }
155
+
156
+ export function showThinking(npcId) {
157
+ if (current) current.stage = "thinking";
158
+ setTyping(false);
159
+ const name = npcId ? npcId : "the office";
160
+ const body = box();
161
+ const zone = document.createElement("div");
162
+ zone.className = "dlg-thinking";
163
+ zone.innerHTML = `&gt; ${name} is thinking<span class="dots"></span>`;
164
+ // replace interactive zones but keep the header + body text
165
+ body.querySelectorAll(".dlg-options,.dlg-quick,.dlg-inputrow,.dlg-urgency")
166
+ .forEach((n) => n.remove());
167
+ body.appendChild(zone);
168
+ }
169
+
170
+ export function showAftermath(outcome, youSaid, onNext) {
171
+ if (current) current.stage = "aftermath";
172
+ const el = box();
173
+ const color = outcome.npc_id ? charColor(outcome.npc_id) : "var(--bds-neon-cyan)";
174
+ el.style.setProperty("--npc-color", color);
175
+ // the portrait reacts to the outcome (designed chibi expression set)
176
+ const slot = el.querySelector("[data-portrait]");
177
+ if (slot && outcome.npc_id) {
178
+ slot.innerHTML = "";
179
+ slot.appendChild(outcomePortrait(outcome.npc_id, outcome.animation));
180
+ }
181
+ const delta = outcome.revenue_delta;
182
+ const deltaTxt = delta === 0 ? "no revenue impact"
183
+ : `${delta > 0 ? "+" : "-"}$${Math.abs(delta).toLocaleString()}`;
184
+ el.querySelector(".dlg-thinking")?.remove();
185
+ el.querySelectorAll(".dlg-body,.dlg-headline").forEach((n) => n.remove());
186
+ const wrap = document.createElement("div");
187
+ wrap.innerHTML = `
188
+ <div class="dlg-after-you">YOU SAID: "${escapeHtml(youSaid).toUpperCase()}"</div>
189
+ <div class="dlg-after-react" data-typed></div>
190
+ <div class="dlg-after-conseq ${delta < 0 ? "down" : ""}">
191
+ &gt; ${escapeHtml(outcome.consequence)}
192
+ <span style="color:${delta >= 0 ? "var(--bds-neon-lime)" : "var(--bds-brad)"}">
193
+ [${deltaTxt}]</span></div>
194
+ ${outcome.salary_paid ? `<div class="dlg-after-you" style="color:var(--bds-neon-lime)">
195
+ &gt; salary advance arrived. +$1,000 pocket money [payday]</div>` : ""}
196
+ ${outcome.relationship_unlocked ? `<div class="dlg-after-you" style="color:#ffc73b">
197
+ &gt; something changed with ${outcome.npc_id}. [relationship deepened]</div>` : ""}
198
+ <button class="btn-next">NEXT &#9654;</button>`;
199
+ el.appendChild(wrap);
200
+ typewrite(wrap.querySelector("[data-typed]"), outcome.npc_reaction);
201
+ el.querySelector(".btn-next").addEventListener("click", onNext);
202
+ }
203
+
204
+ // -------------------------------------------------------------------- idle
205
+
206
+ export function openChat(npcId, npcLine, onReply, onClose) {
207
+ current = { mode: "chat", stage: "question" };
208
+ const color = charColor(npcId);
209
+ show(`
210
+ <div class="dlg-head">
211
+ <div class="dlg-sprite" data-portrait="${npcId}"></div>
212
+ <div><span class="dlg-name">${npcId.toUpperCase()}</span>
213
+ <span class="dlg-title">${NPC_TITLES[npcId]} — JUST CHATTING</span></div>
214
+ </div>
215
+ <div class="dlg-body" data-chatline></div>
216
+ <div class="dlg-inputrow">
217
+ <input class="dlg-input" maxlength="400" placeholder="say something back, or don't...">
218
+ <button class="btn-send">SEND &#9654;</button>
219
+ </div>
220
+ <button class="btn-quick" data-walkaway style="margin-top:8px;">WALK AWAY &#9654;</button>
221
+ `);
222
+ box().style.setProperty("--npc-color", color);
223
+ sfx.open();
224
+ const slot = box().querySelector("[data-portrait]");
225
+ slot.appendChild(crisisPortrait(npcId));
226
+ box().querySelector("[data-walkaway]").addEventListener("click", onClose);
227
+ typewrite(box().querySelector("[data-chatline]"), npcLine);
228
+ bindInput((rt, text) => onReply(text));
229
+ }
230
+
231
+ export function showChatReply(npcLine, relDelta, onClose) {
232
+ if (current) current.stage = "aftermath";
233
+ const el = box();
234
+ el.querySelector(".dlg-thinking")?.remove();
235
+ el.querySelectorAll(".dlg-inputrow,[data-walkaway]").forEach((n) => n.remove());
236
+ const line = el.querySelector("[data-chatline]");
237
+ if (line) typewrite(line, npcLine);
238
+ const wrap = document.createElement("div");
239
+ wrap.innerHTML = `
240
+ ${relDelta ? `<div class="dlg-after-you" style="color:${relDelta > 0
241
+ ? "var(--bds-neon-magenta)" : "var(--bds-grey)"}">
242
+ &gt; that ${relDelta > 0 ? "landed well" : "did not land"}</div>` : ""}
243
+ <button class="btn-next">BACK TO WORK &#9654;</button>`;
244
+ el.appendChild(wrap);
245
+ el.querySelector(".btn-next").addEventListener("click", onClose);
246
+ }
247
+
248
+ export function openEmail(email, onClose) {
249
+ current = { mode: "email", stage: "aftermath" };
250
+ const color = email.sender === "system"
251
+ ? "var(--bds-neon-cyan)" : charColor(email.sender);
252
+ show(`
253
+ <div class="dlg-head"><div>
254
+ <span class="dlg-name" style="color:${color}">FROM:
255
+ ${email.sender.toUpperCase()}@VELOURA</span>
256
+ <span class="dlg-title">SUBJECT: ${escapeHtml(email.subject)}</span>
257
+ </div></div>
258
+ <div class="dlg-body" data-typed></div>
259
+ <button class="btn-next">ARCHIVE FOREVER &#9654;</button>
260
+ `);
261
+ box().style.setProperty("--npc-color", color);
262
+ sfx.open();
263
+ typewrite(box().querySelector("[data-typed]"), email.body);
264
+ box().querySelector(".btn-next").addEventListener("click", onClose);
265
+ }
266
+
267
+ // -------------------------------------------------------------- presentation
268
+
269
+ const TONE_LABEL = { warm: "WARM", neutral: "NEUTRAL",
270
+ concerned: "CONCERNED", alarmed: "ALARMED" };
271
+
272
+ export function openBoardRound(rd, onRespond) {
273
+ current = { mode: "presentation", onRespond, stage: "question" };
274
+ const dots = Array.from({ length: rd.total_rounds }, (_, i) =>
275
+ i < rd.round ? "&#9679;" : "&#9675;").join("");
276
+ const wrongSlide = rd.wrong_slide ? `
277
+ <div class="wrong-slide">
278
+ <div class="ws-photo">
279
+ ${["12,2", "70,4", "2,38", "78,34", "30,-6", "55,52"].map((p) => {
280
+ const [hx, hy] = p.split(",");
281
+ return `<span style="position:absolute;left:${hx}px;top:${hy}px;
282
+ color:var(--bds-neon-magenta);font-size:10px;
283
+ text-shadow:0 0 5px var(--bds-neon-magenta);">&#9829;</span>`;
284
+ }).join("")}
285
+ </div>
286
+ <div class="ws-title">"My Favourite"</div>
287
+ <div>the slide does not advance. everyone has seen it.</div>
288
+ </div>` : "";
289
+ const options = rd.input_only ? "" : `
290
+ <div class="dlg-options">
291
+ <button class="dlg-option a"><span class="opt-label">[1] OPTION A</span>
292
+ ${escapeHtml(rd.option_a)}</button>
293
+ <button class="dlg-option b"><span class="opt-label">[2] OPTION B</span>
294
+ ${escapeHtml(rd.option_b)}</button>
295
+ </div>`;
296
+ const inputNote = rd.input_only
297
+ ? `<div class="dlg-urgency">&gt; type your ${rd.round === 4 ? "answer" : "closing"}. no options here.</div>`
298
+ : "";
299
+ show(`
300
+ <div class="dlg-head">
301
+ <div><span class="dlg-name" style="color:#ffc73b">BOARD OF DIRECTORS &mdash;
302
+ ROUND ${rd.round} OF ${rd.total_rounds}</span>
303
+ <span class="dlg-title">TONE: ${TONE_LABEL[rd.board_tone]}</span></div>
304
+ <span class="round-dots">${dots}</span>
305
+ ${rd.round === 4 ? '<span class="board-badge">! HIGH SCRUTINY</span>' : ""}
306
+ </div>
307
+ ${wrongSlide}
308
+ <div class="dlg-body" data-typed></div>
309
+ ${options}
310
+ ${inputNote}
311
+ ${inputRowHtml(rd.input_only ? "speak. carefully." : "")}
312
+ `, "gold tone-" + rd.board_tone);
313
+ sfx.gold();
314
+ typewrite(box().querySelector("[data-typed]"), rd.board_dialogue);
315
+
316
+ if (rd.wrong_slide) {
317
+ sfx.wrongSlide(); // the cute "oops" sting
318
+ // "the photo" on the wrong slide is the player — the designed chibi
319
+ const photo = box().querySelector(".ws-photo");
320
+ if (photo) photo.prepend(crisisPortrait("player"));
321
+ }
322
+
323
+ if (!rd.input_only) {
324
+ box().querySelector(".dlg-option.a").addEventListener("click",
325
+ () => onRespond("option_a", rd.option_a));
326
+ box().querySelector(".dlg-option.b").addEventListener("click",
327
+ () => onRespond("option_b", rd.option_b));
328
+ }
329
+ bindInput(onRespond);
330
+ }
331
+
332
+ export function showPresentationOutcome(outcome, onClose) {
333
+ const delta = outcome.revenue_delta;
334
+ const deltaTxt = `${delta >= 0 ? "+" : "-"}$${Math.abs(delta).toLocaleString()}`;
335
+ show(`
336
+ <div class="dlg-head"><div>
337
+ <span class="dlg-name" style="color:#ffc73b">PRESENTATION COMPLETE</span>
338
+ <span class="dlg-title">THE BOARD HAS DELIBERATED</span></div></div>
339
+ <div class="dlg-body">
340
+ &gt; board assessment: ${outcome.score}/100<br>
341
+ &gt; revenue impact: <span style="color:${delta >= 0
342
+ ? "var(--bds-neon-lime)" : "var(--bds-brad)"}">${deltaTxt}</span><br>
343
+ ${outcome.budget_unlock ? "&gt; discretionary budget unlocked: +$" +
344
+ outcome.budget_unlock.toLocaleString() + "<br>" : ""}
345
+ ${outcome.board_scrutiny_public
346
+ ? "&gt; board scrutiny: HIGH [!]<br>" : ""}
347
+ &gt; new title: ${escapeHtml(outcome.boss_title).toUpperCase()}
348
+ </div>
349
+ <button class="btn-next">${outcome.final
350
+ ? "FACE THE QUARTERLY REVIEW &#9654;" : "RETURN TO THE FLOOR &#9654;"}</button>
351
+ `, "gold");
352
+ box().querySelector(".btn-next").addEventListener("click", onClose);
353
+ }
354
+
355
+ export function isOpen() { return G.dialogueOpen; }
static/js/effects.js ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // screen-level effects + the 17 animation trigger dispatcher
2
+ import { G } from "./state.js";
3
+ import { confettiBurst, heartFloat, moraleWave, revenueRain } from "./particles.js";
4
+ import { sfx } from "./audio.js";
5
+
6
+ export function dim(on) { G.els.dim.classList.toggle("hidden", !on); }
7
+
8
+ export function redFlash() {
9
+ const el = G.els.flash;
10
+ el.classList.remove("on"); void el.offsetWidth; el.classList.add("on");
11
+ }
12
+
13
+ export function edgePulse(on) { G.els["edge-pulse"].classList.toggle("on", on); }
14
+
15
+ export function hrStamp() {
16
+ const el = G.els.stamp;
17
+ el.classList.remove("hidden");
18
+ el.style.animation = "none"; void el.offsetWidth; el.style.animation = "";
19
+ sfx.stamp();
20
+ setTimeout(() => el.classList.add("hidden"), 950);
21
+ }
22
+
23
+ export function wipe(midCallback) {
24
+ const el = G.els.wipe;
25
+ el.classList.remove("go"); void el.offsetWidth; el.classList.add("go");
26
+ setTimeout(midCallback, 350); // swap scenes while fully covered
27
+ setTimeout(() => el.classList.remove("go"), 750);
28
+ }
29
+
30
+ export function fadeBlack(midCallback, hold = 1000) {
31
+ const el = G.els.wipe;
32
+ el.style.transition = "transform .01s";
33
+ el.style.transform = "translateX(0)";
34
+ el.style.opacity = 0;
35
+ el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 400, fill: "forwards" });
36
+ setTimeout(() => {
37
+ midCallback();
38
+ el.animate([{ opacity: 1 }, { opacity: 0 }],
39
+ { duration: 400, fill: "forwards" });
40
+ setTimeout(() => { el.style.transform = "translateX(-100%)";
41
+ el.style.opacity = 1; el.style.transition = ""; }, 450);
42
+ }, 400 + hold);
43
+ }
44
+
45
+ // the AI's animation enum → visual dispatch (ANIMATIONS.md)
46
+ const NPC_ANIM_POSE = {
47
+ npc_happy: "idle", npc_angry: "shake", npc_confused: "idle",
48
+ npc_devastated: "headdown", npc_celebrating: "idle", npc_hiding: "slump",
49
+ npc_smug: "lean", npc_suspicious: "lean", npc_crying: "slump",
50
+ npc_grateful: "idle",
51
+ };
52
+
53
+ export function playOutcome(trigger, npcId, world) {
54
+ const npc = npcId && world.npcs[npcId];
55
+ switch (trigger) {
56
+ case "disaster_flash": redFlash(); sfx.disaster(); break;
57
+ case "revenue_rain": revenueRain(); sfx.moneyUp(); break;
58
+ case "heart_float":
59
+ if (npc) heartFloat(npc.x, npc.y - 44); sfx.gift(); break;
60
+ case "bribery_envelope": sfx.click(); break;
61
+ case "hr_stamp": hrStamp(); break;
62
+ case "morale_drop_wave": moraleWave(world.npcs); sfx.moneyDown(); break;
63
+ case "confetti_burst": confettiBurst(); sfx.win(); break;
64
+ default:
65
+ if (npc && NPC_ANIM_POSE[trigger]) {
66
+ npc.setPose(NPC_ANIM_POSE[trigger]);
67
+ if (trigger === "npc_celebrating" || trigger === "npc_happy") {
68
+ npc.sprite.animate(
69
+ [{ transform: "translateY(0)" }, { transform: "translateY(-6px)" },
70
+ { transform: "translateY(0)" }],
71
+ { duration: 320, iterations: 2, easing: "steps(3)" });
72
+ }
73
+ }
74
+ }
75
+ }
static/js/hud.js ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // HUD: revenue bar (caps at 96% — the gag), boss title, counters, banner
2
+ import { G } from "./state.js";
3
+
4
+ const fmt = (n) => "$" + Math.abs(n).toLocaleString("en-US");
5
+
6
+ let shownRevenue = 0;
7
+
8
+ export function updateHud(state, animate = true) {
9
+ const els = G.els;
10
+ const target = state.target || 1_000_000;
11
+
12
+ // revenue number counts toward the real value
13
+ const to = state.revenue;
14
+ if (!animate) shownRevenue = to;
15
+ const step = () => {
16
+ const diff = to - shownRevenue;
17
+ if (Math.abs(diff) < 1000) shownRevenue = to;
18
+ else shownRevenue += Math.sign(diff) * Math.max(1000, Math.abs(diff) / 8 | 0);
19
+ els["rev-number"].textContent = fmt(shownRevenue);
20
+ if (shownRevenue !== to) requestAnimationFrame(step);
21
+ };
22
+ step();
23
+
24
+ // bar never quite fills: 96% cap per DESIGN_SYSTEM decisions log
25
+ const pct = Math.min(96, (state.revenue / target) * 100);
26
+ els["rev-fill"].style.width = pct + "%";
27
+
28
+ // color shifts against expected pace through the quarter
29
+ const expected = target * (Math.max(1, state.crisis_number) / 15);
30
+ const ratio = state.crisis_number <= 1 ? 1 : state.revenue / expected;
31
+ const cls = ratio >= 0.75 ? "" : ratio >= 0.4 ? "warn" : "crit";
32
+ els["rev-number"].className = cls;
33
+ els["rev-fill"].className = cls;
34
+
35
+ // boss title cross-fade
36
+ const bt = els["boss-title"];
37
+ const newTitle = (state.boss_title || "").toUpperCase();
38
+ if (bt.textContent !== newTitle) {
39
+ bt.style.opacity = 0;
40
+ setTimeout(() => { bt.textContent = newTitle; bt.style.opacity = 1; }, 250);
41
+ }
42
+
43
+ els["crisis-counter"].textContent =
44
+ `CRISES ${state.crisis_number}/${state.total_crises}`;
45
+ els["pocket"].textContent = `POCKET ${fmt(state.pocket_money)}`;
46
+ els["hr-badge"].classList.toggle("hidden", !state.hr_alert);
47
+ }
48
+
49
+ let bannerTimer = null;
50
+
51
+ export function banner(text, cls = "", sticky = false) {
52
+ const el = G.els["hud-banner"];
53
+ clearTimeout(bannerTimer);
54
+ el.textContent = text;
55
+ el.className = cls;
56
+ el.classList.remove("hidden");
57
+ if (!sticky) bannerTimer = setTimeout(() => el.classList.add("hidden"), 4000);
58
+ }
59
+
60
+ export function clearBanner() {
61
+ clearTimeout(bannerTimer);
62
+ G.els["hud-banner"].classList.add("hidden");
63
+ }
64
+
65
+ export function revenueFloat(delta) {
66
+ const bar = document.getElementById("rev-bar");
67
+ const r = bar.getBoundingClientRect();
68
+ const el = document.createElement("div");
69
+ el.className = "float-text" + (delta < 0 ? " down" : "");
70
+ el.style.cssText = `position:fixed;left:${r.right + 10}px;top:${r.top}px;
71
+ color:${delta >= 0 ? "var(--bds-neon-lime)" : "var(--bds-brad)"};z-index:250;`;
72
+ el.textContent = (delta >= 0 ? "+" : "-") + fmt(delta);
73
+ document.body.appendChild(el);
74
+ setTimeout(() => el.remove(), 1700);
75
+ }
static/js/input.js ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // keyboard + mouse input. GAMEPLAY_FLOW.md controls.
2
+ export const keys = { up: false, down: false, left: false, right: false };
3
+ const handlers = { interact: [], gift: [], escape: [], option: [], mute: [] };
4
+
5
+ export function on(name, fn) { handlers[name].push(fn); }
6
+ function fire(name, arg) { handlers[name].forEach((fn) => fn(arg)); }
7
+ // touch controls fire the same handlers as the keyboard (see touch.js)
8
+ export function emit(name, arg) { fire(name, arg); }
9
+
10
+ const KEYMAP = {
11
+ KeyW: "up", ArrowUp: "up",
12
+ KeyS: "down", ArrowDown: "down",
13
+ KeyA: "left", ArrowLeft: "left",
14
+ KeyD: "right", ArrowRight: "right",
15
+ };
16
+
17
+ export let typingMode = false;
18
+ export function setTyping(v) {
19
+ typingMode = v;
20
+ Object.keys(keys).forEach((k) => (keys[k] = false));
21
+ }
22
+
23
+ window.addEventListener("keydown", (e) => {
24
+ if (typingMode) return; // text input owns the keyboard
25
+ if (KEYMAP[e.code]) { keys[KEYMAP[e.code]] = true; e.preventDefault(); return; }
26
+ if (e.code === "Space" || e.code === "Enter") { fire("interact"); e.preventDefault(); }
27
+ else if (e.code === "KeyG") fire("gift");
28
+ else if (e.code === "Escape") fire("escape");
29
+ else if (e.code === "Digit1") fire("option", "a");
30
+ else if (e.code === "Digit2") fire("option", "b");
31
+ else if (e.code === "KeyM") fire("mute");
32
+ });
33
+
34
+ window.addEventListener("keyup", (e) => {
35
+ if (KEYMAP[e.code]) keys[KEYMAP[e.code]] = false;
36
+ });
37
+
38
+ // click-to-move: main.js registers the stage and receives world coords
39
+ export function bindStageClick(stageEl, fn) {
40
+ stageEl.addEventListener("click", (e) => {
41
+ const r = stageEl.getBoundingClientRect();
42
+ const x = ((e.clientX - r.left) / r.width) * 640;
43
+ const y = ((e.clientY - r.top) / r.height) * 480;
44
+ fn(x, y);
45
+ });
46
+ }
static/js/main.js ADDED
@@ -0,0 +1,684 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // boot, fixed-timestep loop, phase machine, interactions (ARCHITECTURE.md D5)
2
+ import * as api from "./api.js";
3
+ import { G, cacheEls, setState } from "./state.js";
4
+ import { keys, on, bindStageClick, typingMode } from "./input.js";
5
+ import { drawFloor, SPOTS, NPC_SPOTS } from "./map.js";
6
+ import { createPlayer } from "./player.js";
7
+ import { createNpcs } from "./npcs.js";
8
+ import { updateHud, banner, clearBanner, revenueFloat } from "./hud.js";
9
+ import { renderTrail, resetTrail, escapeHtml } from "./papertrail.js";
10
+ import * as dlg from "./dialogue.js";
11
+ import * as comic from "./comic.js";
12
+ import * as fx from "./effects.js";
13
+ import { enterBoardroom, exitBoardroom } from "./boardroom.js";
14
+ import { floatText, steam, confettiBurst, heartFloat } from "./particles.js";
15
+ import { charColor, chibiInBox } from "./sprites.js";
16
+ import { sfx } from "./audio.js";
17
+ import { setupTouch } from "./touch.js";
18
+
19
+ const ROAM_SECONDS = 20;
20
+ const FIRST_CRISIS_SECONDS = 5;
21
+ const INTERACT_DIST = 58; // must beat worst-case approach: blocked below a desk
22
+
23
+ let player, world, ctx;
24
+ let floorOpts = {};
25
+ let inBoardroom = false;
26
+ let worldObjects = { newspaper: null, envelope: null };
27
+
28
+ // ----------------------------------------------------------------- helpers
29
+
30
+ function fitStage() {
31
+ const wrap = G.els["stage-wrap"];
32
+ const scale = Math.min(wrap.clientWidth / 648, wrap.clientHeight / 488, 1.6);
33
+ G.els.stage.style.transform = `scale(${Math.max(scale, 0.5)})`;
34
+ }
35
+
36
+ function arrivalSpot(event) {
37
+ switch (event.arrival) {
38
+ case "newspaper": return SPOTS.newspaperLanding;
39
+ case "envelope": return SPOTS.playerDesk;
40
+ case "phone": return SPOTS.phone;
41
+ case "hr": return SPOTS.inbox;
42
+ case "boardroom": return SPOTS.boardroomDoor;
43
+ default: return NPC_SPOTS[event.affected_npc] || SPOTS.inbox;
44
+ }
45
+ }
46
+
47
+ function promptFor(event) {
48
+ switch (event.arrival) {
49
+ case "newspaper": return "SPACE — PICK UP";
50
+ case "envelope": return "SPACE — OPEN ENVELOPE";
51
+ case "phone": return "SPACE — ANSWER";
52
+ case "hr": return "SPACE — CHECK INBOX";
53
+ case "boardroom": return "ENTER — BOARDROOM";
54
+ default: return "SPACE — TALK";
55
+ }
56
+ }
57
+
58
+ let promptEl = null;
59
+ function showPrompt(x, y, text, gold = false) {
60
+ if (!promptEl) {
61
+ promptEl = document.createElement("div");
62
+ promptEl.className = "prompt-box";
63
+ G.els.fx.appendChild(promptEl);
64
+ }
65
+ promptEl.className = "prompt-box" + (gold ? " gold" : "");
66
+ promptEl.style.left = `${x}px`;
67
+ promptEl.style.top = `${y - 64}px`;
68
+ // on touch there's no keyboard: the on-screen ACT/GIFT buttons do these
69
+ promptEl.textContent = document.body.classList.contains("touch")
70
+ ? text.replace(/SPACE|ENTER/g, "ACT").replace(/\bG\b/g, "GIFT")
71
+ : text;
72
+ promptEl.style.display = "block";
73
+ }
74
+ function hidePrompt() { if (promptEl) promptEl.style.display = "none"; }
75
+
76
+ // --------------------------------------------------------- event arrivals
77
+
78
+ function stageArrival(event) {
79
+ G.pendingEvent = event;
80
+ // kick off the setup comic now so FLUX renders during the walk to the NPC;
81
+ // it's purely decorative — if it isn't ready in time we just skip it.
82
+ G.setupComicImg = null;
83
+ if (event.image_prompt) {
84
+ api.comic(G.sessionId, event.image_prompt)
85
+ .then((r) => { G.setupComicImg = r.image_b64 || null; })
86
+ .catch(() => {});
87
+ }
88
+ switch (event.arrival) {
89
+ case "npc":
90
+ sfx.bubble();
91
+ world.showBubble(event.affected_npc, "danger");
92
+ banner(`${event.affected_npc.toUpperCase()} HAS A SITUATION`);
93
+ break;
94
+ case "npc_amber":
95
+ sfx.amberBubble();
96
+ world.showBubble(event.affected_npc, "amber");
97
+ banner("SOMETHING PERSONAL IS HAPPENING", "gold");
98
+ break;
99
+ case "npc_heart":
100
+ sfx.heart();
101
+ world.showBubble(event.affected_npc, "heart");
102
+ banner(`${event.affected_npc.toUpperCase()} WANTS A WORD...`, "gold");
103
+ break;
104
+ case "newspaper": {
105
+ sfx.newspaper();
106
+ banner("! THE PRESS HAS THE STORY", "red");
107
+ const np = document.createElement("div");
108
+ np.className = "newspaper falling";
109
+ np.style.left = `${SPOTS.newspaperLanding.x - 17}px`;
110
+ np.style.top = `${SPOTS.newspaperLanding.y - 12}px`;
111
+ np.textContent = "VELOURA EXEC DOES SOMETHING — sources say Brad";
112
+ G.els.world.appendChild(np);
113
+ worldObjects.newspaper = np;
114
+ world.npcs.brad.setPose("slump");
115
+ break;
116
+ }
117
+ case "envelope": {
118
+ sfx.envelope();
119
+ const env = document.createElement("div");
120
+ env.className = "envelope";
121
+ env.style.left = `${SPOTS.playerDesk.x + 8}px`;
122
+ env.style.top = `${SPOTS.playerDesk.y + 10}px`;
123
+ G.els.world.appendChild(env);
124
+ worldObjects.envelope = env;
125
+ banner("AN ENVELOPE HAS ARRIVED", "gold");
126
+ break;
127
+ }
128
+ case "phone":
129
+ floorOpts.phoneRing = true;
130
+ sfx.phoneRingStart();
131
+ banner("! CLIENT CALL INCOMING", "red");
132
+ break;
133
+ case "hr":
134
+ fx.hrStamp();
135
+ floorOpts.inboxLit = true;
136
+ banner("! HR WOULD LIKE A WORD", "red", true);
137
+ break;
138
+ case "boardroom":
139
+ floorOpts.doorGlow = true;
140
+ banner("THE BOARD AWAITS — ENTER THE BOARDROOM", "gold", true);
141
+ sfx.gold();
142
+ break;
143
+ }
144
+ }
145
+
146
+ function clearArrivalProps(event) {
147
+ world.clearBubbles();
148
+ floorOpts.phoneRing = false;
149
+ sfx.phoneRingStop();
150
+ floorOpts.inboxLit = false;
151
+ if (event && event.arrival === "newspaper" && worldObjects.newspaper) {
152
+ worldObjects.newspaper.className = "newspaper floor"; // stays. a reminder.
153
+ worldObjects.newspaper = null;
154
+ }
155
+ if (worldObjects.envelope) { worldObjects.envelope.remove();
156
+ worldObjects.envelope = null; }
157
+ }
158
+
159
+ // -------------------------------------------------------------- crisis flow
160
+
161
+ async function fireNextEvent() {
162
+ if (G.busy || G.pendingEvent || dlg.isOpen()) return;
163
+ G.busy = true;
164
+ try {
165
+ const { event, state } = await api.nextEvent(G.sessionId);
166
+ setState(state);
167
+ updateHud(state);
168
+ if (event.kind === "review") { await showReview(); return; }
169
+ stageArrival(event);
170
+ } catch (err) {
171
+ console.error(err);
172
+ G.roamTimer = 6; // retry shortly
173
+ } finally {
174
+ G.busy = false;
175
+ fx.edgePulse(false);
176
+ G.telegraphed = false;
177
+ }
178
+ }
179
+
180
+ function openPendingEvent() {
181
+ const event = G.pendingEvent;
182
+ if (!event) return;
183
+ sfx.phoneRingStop(); // answered
184
+ if (event.kind === "presentation") { startPresentation(event); return; }
185
+ fx.dim(true);
186
+ player.frozen = true;
187
+ // setup comic (if FLUX finished during the walk) plays first, then dialogue
188
+ const setupImg = G.setupComicImg;
189
+ G.setupComicImg = null;
190
+ comic.showComic(setupImg, event.comic_caption, () => dlg.openCrisis(event, async (responseType, text) => {
191
+ if (G.busy) return;
192
+ G.busy = true;
193
+ const optLabel = event.special === "romance"
194
+ ? { option_a: "LEAN IN", option_b: "KEEP IT PROFESSIONAL" }
195
+ : event.special === "bribery"
196
+ ? { option_a: "ACCEPT", option_b: "DECLINE" }
197
+ : { option_a: "OPTION A", option_b: "OPTION B" };
198
+ const youSaid = text || optLabel[responseType] || responseType;
199
+ dlg.showThinking(event.affected_npc !== "player"
200
+ ? event.affected_npc : "the office");
201
+ const t0 = Date.now();
202
+ try {
203
+ const { outcome, state } = await api.respond(G.sessionId, responseType, text);
204
+ // fallbacks land fast — keep the thinking rhythm consistent (UI_UX §22)
205
+ const wait = Math.max(0, 1200 - (Date.now() - t0));
206
+ setTimeout(() => {
207
+ setState(state);
208
+ clearArrivalProps(event);
209
+ G.pendingEvent = null;
210
+ updateHud(state);
211
+ renderTrail(state.paper_trail);
212
+ if (outcome.revenue_delta !== 0) {
213
+ revenueFloat(outcome.revenue_delta);
214
+ (outcome.revenue_delta > 0 ? sfx.moneyUp : sfx.moneyDown)();
215
+ }
216
+ fx.playOutcome(outcome.animation, outcome.npc_id, world);
217
+ world.applyMoods(state.npc_moods);
218
+ floorOpts.gloomy = state.ambient === "gloomy";
219
+ const showAfter = () => dlg.showAftermath(outcome, youSaid, () => {
220
+ dlg.close();
221
+ fx.dim(false);
222
+ player.frozen = false;
223
+ startRoam();
224
+ });
225
+ // payoff comic: only exists after the outcome lands, so it renders now
226
+ // (thinking box stays up) and swaps to the aftermath when dismissed
227
+ if (outcome.image_prompt) {
228
+ api.comic(G.sessionId, outcome.image_prompt)
229
+ .then((r) => comic.showComic(r.image_b64, outcome.comic_caption, showAfter))
230
+ .catch(showAfter);
231
+ } else {
232
+ showAfter();
233
+ }
234
+ G.busy = false;
235
+ }, wait);
236
+ } catch (err) {
237
+ console.error(err);
238
+ G.busy = false;
239
+ dlg.close(); fx.dim(false); player.frozen = false; startRoam();
240
+ }
241
+ }));
242
+ }
243
+
244
+ function startRoam() {
245
+ clearBanner();
246
+ if (G.state && G.state.phase === "review") { showReview(); return; }
247
+ G.roamTimer = ROAM_SECONDS;
248
+ G.telegraphed = false;
249
+ G.idleRolled = false;
250
+ G.idleAt = ROAM_SECONDS - 6;
251
+ chattedNpcs.clear();
252
+ }
253
+
254
+ // -------------------------------------------------------- presentation flow
255
+
256
+ async function startPresentation(event) {
257
+ G.pendingEvent = null; // consume — re-pressing SPACE must not re-enter
258
+ floorOpts.doorGlow = false;
259
+ clearBanner();
260
+ player.frozen = true;
261
+ G.busy = true;
262
+ try {
263
+ const { round_data, state } = await api.presentationRound(G.sessionId, "", "");
264
+ setState(state);
265
+ fx.wipe(() => {
266
+ inBoardroom = true;
267
+ G.els.world.style.visibility = "hidden";
268
+ enterBoardroom(round_data.presenting_npc, round_data.npc_state);
269
+ setTimeout(() => presentRound(round_data), 1200);
270
+ });
271
+ } catch (err) { console.error(err); player.frozen = false; }
272
+ G.busy = false;
273
+ }
274
+
275
+ function presentRound(rd) {
276
+ dlg.openBoardRound(rd, async (responseType, text) => {
277
+ if (G.busy) return;
278
+ G.busy = true;
279
+ dlg.showThinking("the board");
280
+ try {
281
+ const { round_data, state } = await api.presentationRound(
282
+ G.sessionId, responseType, text);
283
+ setState(state);
284
+ updateHud(state);
285
+ if (round_data.kind === "round") {
286
+ presentRound(round_data);
287
+ } else {
288
+ // outcome
289
+ renderTrail(state.paper_trail);
290
+ if (round_data.revenue_delta !== 0) revenueFloat(round_data.revenue_delta);
291
+ (round_data.revenue_delta >= 0 ? sfx.moneyUp : sfx.moneyDown)();
292
+ dlg.showPresentationOutcome(round_data, async () => {
293
+ dlg.close();
294
+ G.pendingEvent = null;
295
+ if (round_data.final) { await showReview(); return; }
296
+ fx.wipe(() => {
297
+ exitBoardroom();
298
+ inBoardroom = false;
299
+ G.els.world.style.visibility = "visible";
300
+ player.frozen = false;
301
+ world.applyMoods(G.state.npc_moods);
302
+ startRoam();
303
+ });
304
+ });
305
+ }
306
+ } catch (err) { console.error(err); }
307
+ G.busy = false;
308
+ });
309
+ }
310
+
311
+ // -------------------------------------------------------------- review flow
312
+
313
+ const TIER_COLOR = {
314
+ hit_target: "var(--bds-neon-lime)", above_600k: "var(--bds-kevin)",
315
+ "300k_to_600k": "#ff9c3c", below_300k: "var(--bds-brad)",
316
+ };
317
+ const TIER_HEAD = {
318
+ hit_target: "THE GAME SHIPPED. SOMEHOW.",
319
+ above_600k: "THE BOARD IS NOT NOT IMPRESSED",
320
+ "300k_to_600k": "THE BOARD WANTS A CALL",
321
+ below_300k: "THE BOARD HAS DRAFTED SOMETHING",
322
+ };
323
+
324
+ async function showReview() {
325
+ dlg.close(); fx.dim(false);
326
+ let data;
327
+ try { ({ review: data } = await api.review(G.sessionId)); }
328
+ catch (err) { console.error(err); return; }
329
+ const el = G.els["review-screen"];
330
+ const color = TIER_COLOR[data.tier];
331
+ el.innerHTML = `
332
+ <div class="rv-card" style="--rv-color:${color}">
333
+ <div class="rv-head">Q3 QUARTERLY REVIEW</div>
334
+ <div class="rv-revenue" id="rv-count">$0</div>
335
+ <div class="rv-gap">${data.gap >= 0 ? "+" : "-"}$${Math.abs(data.gap)
336
+ .toLocaleString()} vs $1,000,000 target</div>
337
+ <div class="rv-line">${TIER_HEAD[data.tier]}</div>
338
+ <div class="rv-section">
339
+ <div class="rv-line">FINAL TITLE: <span class="rv-title">
340
+ ${escapeHtml(data.boss_title).toUpperCase()}</span></div>
341
+ <div class="rv-line">CRISES SURVIVED: ${data.crises_survived} ·
342
+ PRESS DISASTERS: ${data.press_disasters}</div>
343
+ </div>
344
+ <div class="rv-section">
345
+ <div class="rv-sec-head">THE QUARTER'S GREATEST HITS</div>
346
+ ${data.highlights.map((h) => `<div class="rv-line">
347
+ <span style="color:${h.npc === "board" ? "#ffc73b" : charColor(h.npc)}">
348
+ ${(h.npc || "?").toUpperCase()}</span> — ${escapeHtml(h.text)}</div>`).join("")}
349
+ </div>
350
+ <div class="rv-section">
351
+ <div class="rv-sec-head">THE BOARD'S VERDICT</div>
352
+ <div class="rv-verdict">${escapeHtml(data.verdict)}</div>
353
+ </div>
354
+ <button class="btn-cta" id="btn-again" style="margin-top:14px;">
355
+ PLAY AGAIN &#9654;</button>
356
+ </div>`;
357
+ sfx.musicStop(); // the office goes quiet for the verdict
358
+ fx.fadeBlack(() => {
359
+ el.classList.remove("hidden");
360
+ sfx.review();
361
+ // revenue count-up
362
+ const span = el.querySelector("#rv-count");
363
+ const total = data.final_revenue;
364
+ let cur = 0;
365
+ const tick = () => {
366
+ cur = Math.min(total, cur + Math.max(5000, total / 60 | 0));
367
+ span.textContent = "$" + cur.toLocaleString();
368
+ if (cur < total) requestAnimationFrame(tick);
369
+ };
370
+ tick();
371
+ if (data.tier === "hit_target") { confettiBurst(); setTimeout(() => sfx.win(), 500); }
372
+ else setTimeout(() => sfx.lose(data.tier), 600);
373
+ }, 800);
374
+ el.querySelector("#btn-again").addEventListener("click", () => location.reload());
375
+ }
376
+
377
+ // ------------------------------------------------------------ idle moments
378
+
379
+ const chattedNpcs = new Set();
380
+
381
+ async function rollIdleMoment() {
382
+ if (G.idleRolled || G.busy) return;
383
+ G.idleRolled = true;
384
+ try {
385
+ const { idle, state } = await api.idle(G.sessionId);
386
+ setState(state);
387
+ if (idle.kind === "banter") {
388
+ world.sayBubble(idle.npc_id, idle.line, 5200);
389
+ } else if (idle.kind === "eavesdrop") {
390
+ world.saySequence(idle.lines);
391
+ } else if (idle.kind === "email_waiting") {
392
+ floorOpts.inboxLit = true;
393
+ sfx.mail();
394
+ banner("YOU'VE GOT (INTERNAL) MAIL");
395
+ }
396
+ } catch (err) {
397
+ // cap hit or phase changed mid-flight — fine, stay quiet
398
+ if (err.status !== 409) console.error(err);
399
+ }
400
+ }
401
+
402
+ async function startChat(npcId) {
403
+ if (G.busy || chattedNpcs.has(npcId)) return;
404
+ G.busy = true;
405
+ player.frozen = true;
406
+ fx.dim(true);
407
+ try {
408
+ const { chat } = await api.chat(G.sessionId, npcId, "");
409
+ chattedNpcs.add(npcId);
410
+ dlg.openChat(npcId, chat.npc_line, async (text) => {
411
+ if (G.busy) return;
412
+ G.busy = true;
413
+ dlg.showThinking(npcId);
414
+ try {
415
+ const { chat: reply, state } = await api.chat(G.sessionId, npcId, text);
416
+ setState(state);
417
+ world.applyMoods(state.npc_moods);
418
+ dlg.showChatReply(reply.npc_line, reply.relationship_delta, endChat);
419
+ if (reply.relationship_delta) {
420
+ const npc = world.npcs[npcId];
421
+ floatText(npc.x, npc.y - 36,
422
+ (reply.relationship_delta > 0 ? "+" : "") + reply.relationship_delta,
423
+ reply.relationship_delta > 0
424
+ ? "var(--bds-neon-magenta)" : "var(--bds-grey)");
425
+ }
426
+ } catch (err) { console.error(err); endChat(); }
427
+ G.busy = false;
428
+ }, endChat);
429
+ } catch (err) {
430
+ if (err.status === 409) {
431
+ const npc = world.npcs[npcId];
432
+ floatText(npc.x, npc.y - 36, "...", "var(--bds-grey-dim)");
433
+ } else console.error(err);
434
+ fx.dim(false);
435
+ player.frozen = false;
436
+ }
437
+ G.busy = false;
438
+ }
439
+
440
+ function endChat() {
441
+ dlg.close();
442
+ fx.dim(false);
443
+ player.frozen = false;
444
+ }
445
+
446
+ async function openInboxEmail() {
447
+ if (G.busy) return;
448
+ try {
449
+ const { email, state } = await api.readEmail(G.sessionId);
450
+ setState(state);
451
+ floorOpts.inboxLit = false;
452
+ clearBanner();
453
+ player.frozen = true;
454
+ fx.dim(true);
455
+ dlg.openEmail(email, endChat);
456
+ } catch (err) { if (err.status !== 409) console.error(err); }
457
+ }
458
+
459
+ // ----------------------------------------------------------------- gifting
460
+
461
+ function openGiftPanel(npcId) {
462
+ if (G.state.phase !== "free_roam" || G.pendingEvent) return;
463
+ const panel = G.els["gift-panel"];
464
+ const tiers = [["small", "FLOWERS", 200], ["medium", "LUNCH", 350],
465
+ ["large", "GIFT CARD", 500]];
466
+ panel.style.setProperty("--npc-color", charColor(npcId));
467
+ panel.innerHTML = `
468
+ <div class="gift-head">GIFT FOR ${npcId.toUpperCase()}</div>
469
+ <div class="gift-balance">POCKET: $${G.state.pocket_money.toLocaleString()}
470
+ — this is YOUR money</div>
471
+ ${tiers.map(([tier, label, cost]) => `
472
+ <button class="gift-row" data-tier="${tier}"
473
+ ${G.state.pocket_money < cost ? "disabled" : ""}>
474
+ <span>${label}</span><span>$${cost}</span></button>`).join("")}
475
+ <button class="gift-cancel">ESC — NEVER MIND</button>`;
476
+ panel.classList.remove("hidden");
477
+ panel.querySelectorAll(".gift-row").forEach((b) =>
478
+ b.addEventListener("click", async () => {
479
+ panel.classList.add("hidden");
480
+ try {
481
+ const { result, state } = await api.gift(G.sessionId, npcId, b.dataset.tier);
482
+ setState(state); updateHud(state);
483
+ const npc = world.npcs[npcId];
484
+ fx.playOutcome("heart_float", npcId, world);
485
+ floatText(npc.x, npc.y - 30,
486
+ `+${result.relationship_delta}${result.halved ? " (again so soon?)" : ""}`,
487
+ "var(--bds-neon-magenta)");
488
+ if (result.unlocked) floatText(npc.x, npc.y - 46, "✓ something changed",
489
+ "#ffc73b");
490
+ world.applyMoods(state.npc_moods);
491
+ } catch (err) { console.error(err); }
492
+ }));
493
+ panel.querySelector(".gift-cancel").addEventListener("click",
494
+ () => panel.classList.add("hidden"));
495
+ }
496
+
497
+ async function buyCoffeeRound() {
498
+ if (G.state.pocket_money < 50) {
499
+ floatText(SPOTS.coffee.x, SPOTS.coffee.y - 20, "NO FUNDS", "var(--bds-brad)");
500
+ return;
501
+ }
502
+ try {
503
+ const { state } = await api.gift(G.sessionId, "brad", "coffee");
504
+ setState(state); updateHud(state);
505
+ floatText(SPOTS.coffee.x, SPOTS.coffee.y - 24, "TEAM COFFEE -$50",
506
+ "var(--bds-neon-lime)");
507
+ sfx.coffee();
508
+ world.applyMoods(state.npc_moods);
509
+ } catch (err) { console.error(err); }
510
+ }
511
+
512
+ // -------------------------------------------------------------- interaction
513
+
514
+ function nearestInteraction() {
515
+ if (G.pendingEvent) {
516
+ const spot = arrivalSpot(G.pendingEvent);
517
+ if (player.distTo(spot) < INTERACT_DIST + (G.pendingEvent.kind ===
518
+ "presentation" ? 16 : 0))
519
+ return { kind: "event", spot, prompt: promptFor(G.pendingEvent),
520
+ gold: G.pendingEvent.kind === "presentation" };
521
+ }
522
+ if (G.state && G.state.phase === "free_roam" && !G.pendingEvent) {
523
+ if (G.state.email_waiting && player.distTo(SPOTS.inbox) < INTERACT_DIST)
524
+ return { kind: "email", spot: SPOTS.inbox, prompt: "SPACE — READ EMAIL" };
525
+ for (const [id, spot] of Object.entries(NPC_SPOTS))
526
+ if (player.distTo(spot) < INTERACT_DIST) {
527
+ const talked = chattedNpcs.has(id);
528
+ const parts = [];
529
+ if (!talked) parts.push("SPACE — TALK");
530
+ if (G.state.gift_available) parts.push("G — GIFT");
531
+ return { kind: "npc_idle", npcId: id, spot,
532
+ prompt: parts.join(" / ") || "..." };
533
+ }
534
+ if (player.distTo(SPOTS.coffee) < INTERACT_DIST)
535
+ return { kind: "coffee", spot: SPOTS.coffee, prompt: "SPACE — TEAM COFFEE $50" };
536
+ }
537
+ return null;
538
+ }
539
+
540
+ function handleInteract() {
541
+ if (comic.isOpen()) { comic.dismissComic(); return; } // SPACE advances comic
542
+ if (dlg.isOpen()) return;
543
+ const hit = nearestInteraction();
544
+ if (!hit) return;
545
+ if (hit.kind === "event") openPendingEvent();
546
+ else if (hit.kind === "coffee") buyCoffeeRound();
547
+ else if (hit.kind === "email") openInboxEmail();
548
+ else if (hit.kind === "npc_idle" && !chattedNpcs.has(hit.npcId))
549
+ startChat(hit.npcId);
550
+ }
551
+
552
+ // --------------------------------------------------------------------- loop
553
+
554
+ let last = 0;
555
+ function loop(ts) {
556
+ const dt = Math.min(0.05, (ts - last) / 1000 || 0.016);
557
+ last = ts;
558
+
559
+ if (!inBoardroom && G.phase !== "title") {
560
+ drawFloor(ctx, floorOpts);
561
+ player.update(dt, keys);
562
+
563
+ // soft footsteps while walking (rate-limited inside sfx)
564
+ if (player.pose === "walk" && !player.frozen) sfx.footstep();
565
+
566
+ // roam countdown → next event
567
+ if (G.state && G.state.phase === "free_roam" && !G.pendingEvent &&
568
+ !dlg.isOpen() && !G.busy && !G.state.game_over) {
569
+ G.roamTimer -= dt;
570
+ if (!G.idleRolled && G.idleAt >= 0 && G.roamTimer <= G.idleAt)
571
+ rollIdleMoment(); // one AI ambient moment per gap, ~6s in
572
+ if (G.roamTimer <= 3 && !G.telegraphed) {
573
+ fx.edgePulse(true); G.telegraphed = true;
574
+ }
575
+ if (G.roamTimer <= 0) fireNextEvent();
576
+ }
577
+
578
+ // proximity prompt
579
+ const hit = nearestInteraction();
580
+ if (hit) showPrompt(hit.spot.x, hit.spot.y, hit.prompt, hit.gold);
581
+ else hidePrompt();
582
+
583
+ // ambient steam
584
+ if (Math.random() < 0.02) steam(SPOTS.coffee.x, SPOTS.coffee.y - 14);
585
+
586
+ // a heart drifts over whoever the player is dating
587
+ if (G.state && Math.random() < 0.012) {
588
+ const romancing = Object.entries(G.state.npc_romance || {})
589
+ .find(([, s]) => s === "active");
590
+ if (romancing) {
591
+ const npc = world.npcs[romancing[0]];
592
+ if (npc) heartFloat(npc.x, npc.y - 44);
593
+ }
594
+ }
595
+ }
596
+ requestAnimationFrame(loop);
597
+ }
598
+
599
+ // --------------------------------------------------------------------- boot
600
+
601
+ async function startGame() {
602
+ const { session_id, state } = await api.newGame();
603
+ G.sessionId = session_id;
604
+ setState(state);
605
+ resetTrail();
606
+ updateHud(state, false);
607
+ G.els["title-screen"].classList.add("hidden");
608
+ G.els.hud.classList.remove("hidden");
609
+ requestAnimationFrame(fitStage); // re-fit now that the HUD shrinks the frame
610
+ banner("> a crisis approaches...", "");
611
+ G.roamTimer = FIRST_CRISIS_SECONDS;
612
+ G.idleRolled = false;
613
+ G.idleAt = -1; // no ambient moment in the short intro gap
614
+ sfx.open();
615
+ sfx.musicStart(); // looping background music (static/audio/bgm.mp3)
616
+ }
617
+
618
+ function buildTitleCast() {
619
+ const cast = document.getElementById("ts-cast");
620
+ if (!cast) return;
621
+ const lineup = [
622
+ ["brad", { armPose: "out", mouth: "open" }],
623
+ ["stacey", { armPose: "wring", mouth: "smile" }],
624
+ ["kevin", { armPose: "point", mouth: "smirk" }],
625
+ ["janet", { armPose: "open", mouth: "open" }],
626
+ ["derek", { mouth: "flat" }],
627
+ ];
628
+ for (const [id, expr] of lineup) {
629
+ const slot = document.createElement("div");
630
+ slot.className = "cast-slot";
631
+ slot.appendChild(chibiInBox(id, expr, 60, 72));
632
+ const name = document.createElement("div");
633
+ name.className = "cast-name";
634
+ name.textContent = id.toUpperCase();
635
+ name.style.color = charColor(id);
636
+ slot.appendChild(name);
637
+ cast.appendChild(slot);
638
+ }
639
+ }
640
+
641
+ function init() {
642
+ cacheEls();
643
+ window.BDS_DEBUG = G; // read-only debug/test handle
644
+ buildTitleCast();
645
+ setupTouch(); // on-screen joystick + buttons for phones/tablets
646
+ api.warm().catch(() => {}); // wake Modal while the player reads the title
647
+ ctx = G.els.floor.getContext("2d");
648
+ player = createPlayer(G.els.world);
649
+ world = createNpcs(G.els.world);
650
+ fitStage();
651
+ window.addEventListener("resize", fitStage);
652
+
653
+ document.getElementById("btn-start").addEventListener("click", startGame);
654
+
655
+ on("interact", handleInteract);
656
+ on("gift", () => {
657
+ const hit = nearestInteraction();
658
+ if (hit && hit.kind === "npc_idle" && G.state.gift_available)
659
+ openGiftPanel(hit.npcId);
660
+ });
661
+ on("escape", () => {
662
+ G.els["gift-panel"].classList.add("hidden");
663
+ // ESC closes a crisis dialogue only before a response is sent —
664
+ // the crisis stays pending (bubble remains, walk back to reopen)
665
+ if (dlg.isOpen() && dlg.stage() === "question" && !G.busy &&
666
+ G.pendingEvent && G.pendingEvent.kind !== "presentation") {
667
+ dlg.close();
668
+ fx.dim(false);
669
+ player.frozen = false;
670
+ }
671
+ });
672
+ on("option", (which) => dlg.chooseOption(which));
673
+ on("mute", () => sfx.toggleMute());
674
+
675
+ bindStageClick(G.els.stage, (x, y) => {
676
+ if (dlg.isOpen() || player.frozen || typingMode) return;
677
+ player.target = { x, y };
678
+ // clicking near an interactable walks there; arrival handled by prompts
679
+ });
680
+
681
+ requestAnimationFrame(loop);
682
+ }
683
+
684
+ init();
static/js/map.js ADDED
@@ -0,0 +1,662 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // office geometry + canvas floor rendering. 640x480 internal (UI_UX.md).
2
+ // COZY 16-BIT DEPTH PASS (user reference): every piece of furniture has a
3
+ // dark outline, a lit top surface and a shaded front face; walls have
4
+ // height; objects cast contact shadows. Geometry/colliders unchanged.
5
+ // The static scene is pre-rendered once to an offscreen canvas; only
6
+ // animated props (clock, rain, ringing phone, door glow…) draw per frame.
7
+ export const W = 640, H = 480, WALL = 10;
8
+
9
+ export const ROOMS = {
10
+ boardroom: { x: WALL, y: WALL, w: 220, h: 160 }, // upper left
11
+ breakroom: { x: 450, y: WALL, w: 180, h: 140 }, // upper right
12
+ };
13
+
14
+ export const SPOTS = {
15
+ playerDesk: { x: 150, y: 255 },
16
+ inbox: { x: 205, y: 252 },
17
+ coffee: { x: 590, y: 64 },
18
+ plant: { x: 612, y: 446 },
19
+ printer: { x: 420, y: 436 },
20
+ clock: { x: 330, y: 40 },
21
+ window: { x: 4, y: 250 },
22
+ phone: { x: 185, y: 250 }, // interact spot beside the desk, open approach
23
+ boardroomDoor: { x: 236, y: 120 },
24
+ newspaperLanding: { x: 330, y: 290 },
25
+ reception: { x: 320, y: 446 },
26
+ playerStart: { x: 320, y: 300 },
27
+ };
28
+
29
+ export const NPC_SPOTS = {
30
+ brad: { x: 310, y: 205 },
31
+ stacey: { x: 470, y: 235 },
32
+ kevin: { x: 560, y: 335 },
33
+ janet: { x: 360, y: 385 },
34
+ derek: { x: 175, y: 385 },
35
+ };
36
+
37
+ function deskCollider(s) { return { x: s.x - 26, y: s.y + 6, w: 52, h: 34 }; }
38
+
39
+ export const COLLIDERS = [
40
+ // outer walls (top wall is deep: it has a visible face)
41
+ { x: 0, y: 0, w: W, h: 26 }, { x: 0, y: H - WALL, w: W, h: WALL },
42
+ { x: 0, y: 0, w: WALL, h: H }, { x: W - WALL, y: 0, w: WALL, h: H },
43
+ // boardroom is a solid block in office view — entered via the door scene
44
+ { x: ROOMS.boardroom.x, y: ROOMS.boardroom.y,
45
+ w: ROOMS.boardroom.w, h: ROOMS.boardroom.h },
46
+ // break room walls with an opening at the bottom (x 500..560)
47
+ { x: 450, y: WALL, w: 6, h: 140 }, // left wall
48
+ { x: 450, y: 144, w: 50, h: 6 }, // bottom left segment
49
+ { x: 560, y: 144, w: 70, h: 6 }, // bottom right segment
50
+ // coffee machine + props
51
+ { x: SPOTS.coffee.x - 12, y: SPOTS.coffee.y - 10, w: 26, h: 24 },
52
+ { x: SPOTS.plant.x - 8, y: SPOTS.plant.y - 6, w: 18, h: 20 },
53
+ { x: SPOTS.printer.x - 14, y: SPOTS.printer.y - 8, w: 30, h: 22 },
54
+ { x: SPOTS.reception.x - 30, y: SPOTS.reception.y - 6, w: 60, h: 18 },
55
+ // desks
56
+ deskCollider(SPOTS.playerDesk),
57
+ ...Object.values(NPC_SPOTS).map(deskCollider),
58
+ ];
59
+
60
+ export function collides(px, py) {
61
+ // player feet box: 16 wide, 10 tall, anchored at sprite bottom-center
62
+ const box = { x: px - 8, y: py - 4, w: 16, h: 10 };
63
+ return COLLIDERS.some((c) =>
64
+ box.x < c.x + c.w && box.x + box.w > c.x &&
65
+ box.y < c.y + c.h && box.y + box.h > c.y);
66
+ }
67
+
68
+ /* ---------------------------------------------------------------- palette */
69
+ // aligned to the Design System warmlight theme (office34.js THEMES.warmlight)
70
+ const P = {
71
+ ink: "#2e2a22", // the 16-bit outline — warm dark ink
72
+ tileA: "#8d8b82", tileB: "#878579", tileSeam: "#76746b",
73
+ wallCap: "#ece8dc", wallFace: "#d8d4c8", wallShade: "#bcb7a8",
74
+ baseboard: "#b4af9f",
75
+ glass: "rgba(140,200,235,.32)", glassFrame: "#8e94a6",
76
+ glassLine: "rgba(255,255,255,.65)",
77
+ sky: "#aedcf2", skyHi: "#cdeaf8", bldg: "#9fb2c4", bldgLit: "#f4f9fd",
78
+ bldgLit2: "#ffe9a8",
79
+ woodFloorA: "#b58a55", woodFloorB: "#a87c45", woodSeam: "#8a6230",
80
+ meetFloorA: "#878579", meetFloorB: "#807e72",
81
+ woodTop: "#c3a169", woodTopHi: "#dcbb82", woodFace: "#997b46",
82
+ deskTop: "#e9e4d3", deskTopHi: "#f4f0e2", deskFace: "#c6c2b6",
83
+ deskFaceLo: "#b3ab95",
84
+ partTop: "#ece8dc", partFace: "#bdb8a6", partCloth: "#a7a08c",
85
+ monitor: "#1c3a5a", monitorFrame: "#2b2e3a", monitorSide: "#23242c",
86
+ screenGlow: "#7fb2ec",
87
+ chairTop: "#3b3e46", chairHi: "#4a4e58", chairFace: "#2f323a",
88
+ paper: "#faf7ee", paperShadow: "#c7c0aa",
89
+ metalTop: "#cfccc4", metalHi: "#e6e2d6", metalFace: "#a8a392",
90
+ shadow: "rgba(46,42,34,.30)", shadowSoft: "rgba(46,42,34,.16)",
91
+ plantPot: "#b35c3a", plantPotHi: "#cf7450", leaf: "#3f9e52",
92
+ leafHi: "#5cc46e", leafLo: "#2c7a3d",
93
+ mug: "#e25b4a", book: "#8e6fd8", folder: "#3aa3e0", binder2: "#e6b33c",
94
+ binder3: "#d9534f",
95
+ lamp: "#5b5e6b", lampGlow: "#ffd98c",
96
+ case: "#2e2f37", caseHi: "#46474f",
97
+ label: "#6e6e7e",
98
+ };
99
+
100
+ let staticScene = null; // offscreen canvas, built once
101
+ let rainDrops = [];
102
+
103
+ // labels are baked into the static scene — rebuild once the pixel font lands
104
+ if (typeof document !== "undefined" && document.fonts)
105
+ document.fonts.ready.then(() => { staticScene = null; });
106
+
107
+ /* ------------------------------------------------------------ tiny helpers */
108
+ function r(c, x, y, w, h, color) { c.fillStyle = color; c.fillRect(x, y, w, h); }
109
+
110
+ function shade(c, x, y, w, soft) {
111
+ // contact shadow: a wide low rect hugging the object's south edge
112
+ r(c, x + 1, y, w - 2, 4, soft ? P.shadowSoft : P.shadow);
113
+ r(c, x + 3, y + 4, w - 6, 2, P.shadowSoft);
114
+ }
115
+
116
+ /* the core 16-bit primitive: outlined block with lit top + shaded front face */
117
+ function box3d(c, x, y, w, topH, faceH, top, face, hi, outline = P.ink) {
118
+ r(c, x - 1, y - 1, w + 2, topH + faceH + 2, outline);
119
+ r(c, x, y, w, topH, top);
120
+ if (hi) r(c, x, y, w, 2, hi);
121
+ r(c, x, y + topH, w, faceH, face);
122
+ r(c, x, y + topH, w, 1, "rgba(0,0,0,.14)"); // crease under the lip
123
+ }
124
+
125
+ function plant(c, x, y, big) {
126
+ const s = big ? 1.4 : 1;
127
+ shade(c, x - 8 * s, y + 9 * s, 17 * s, true);
128
+ box3d(c, x - 6 * s, y, 12 * s, 3 * s, 8 * s, P.plantPotHi, P.plantPot);
129
+ r(c, x - 8 * s, y - 9 * s, 6 * s, 10 * s, P.leaf);
130
+ r(c, x + 1 * s, y - 12 * s, 6 * s, 13 * s, P.leafHi);
131
+ r(c, x - 3 * s, y - 7 * s, 5 * s, 8 * s, P.leafLo);
132
+ r(c, x - 1 * s, y - 15 * s, 3 * s, 7 * s, P.leaf);
133
+ r(c, x - 2 * s, y - 16 * s, 1, 1, "#7fe08f");
134
+ }
135
+
136
+ function chair(c, x, y) {
137
+ shade(c, x - 7, y + 7, 15, true);
138
+ box3d(c, x - 5, y - 9, 11, 6, 3, P.chairTop, P.chairFace, P.chairHi); // back
139
+ box3d(c, x - 6, y - 1, 13, 5, 4, P.chairTop, P.chairFace, P.chairHi); // seat
140
+ }
141
+
142
+ function monitor(c, x, y, lit = true) {
143
+ r(c, x - 9, y - 7, 19, 15, P.ink);
144
+ r(c, x - 8, y - 6, 17, 13, P.monitorFrame);
145
+ r(c, x + 7, y - 6, 2, 13, P.monitorSide); // CRT side depth
146
+ r(c, x - 6, y - 4, 12, 8, P.monitor);
147
+ if (lit) {
148
+ r(c, x - 5, y - 3, 8, 1, P.screenGlow);
149
+ r(c, x - 5, y - 1, 5, 1, "#4d7fc0");
150
+ r(c, x - 5, y + 1, 7, 1, "#4d7fc0");
151
+ }
152
+ r(c, x - 2, y + 7, 5, 2, P.ink); // stand
153
+ }
154
+
155
+ function papers(c, x, y) {
156
+ r(c, x, y, 8, 6, P.paperShadow);
157
+ r(c, x - 1, y - 1, 8, 6, P.paper);
158
+ r(c, x - 1, y - 1, 8, 1, "#f0f0f4");
159
+ r(c, x + 1, y + 1, 5, 1, P.paperShadow);
160
+ }
161
+
162
+ function keyboard(c, x, y) {
163
+ r(c, x - 1, y - 1, 14, 7, P.ink);
164
+ r(c, x, y, 12, 5, "#a4a7b4");
165
+ r(c, x + 1, y + 1, 10, 1, "#c9ccd6");
166
+ }
167
+
168
+ function deskLamp(c, x, y) {
169
+ r(c, x, y + 4, 6, 2, P.lamp); // base
170
+ r(c, x + 2, y - 2, 2, 6, P.lamp); // arm
171
+ r(c, x, y - 5, 7, 4, P.ink); // head
172
+ r(c, x + 1, y - 4, 5, 2, P.lamp);
173
+ r(c, x + 1, y - 1, 5, 1, P.lampGlow); // warm light
174
+ }
175
+
176
+ function landline(c, x, y) {
177
+ r(c, x - 1, y - 1, 12, 9, P.ink);
178
+ r(c, x, y, 10, 7, "#b9bcc8");
179
+ r(c, x, y, 10, 2, "#d4d6de");
180
+ r(c, x + 1, y - 3, 8, 3, "#8e919e"); // handset on top
181
+ r(c, x + 6, y + 3, 3, 3, "#7d8290"); // keypad
182
+ }
183
+
184
+ function briefcase(c, x, y) {
185
+ shade(c, x - 1, y + 9, 14, true);
186
+ r(c, x - 1, y - 1, 14, 11, P.ink);
187
+ r(c, x, y, 12, 9, P.case);
188
+ r(c, x, y, 12, 2, P.caseHi);
189
+ r(c, x + 4, y - 3, 4, 3, P.ink); // handle
190
+ r(c, x + 5, y - 2, 2, 1, P.case);
191
+ r(c, x + 5, y + 4, 2, 2, "#8c8f9b"); // clasp
192
+ }
193
+
194
+ function shelfUnit(c, x, y) {
195
+ box3d(c, x, y, 40, 16, 7, "#e8e8ed", "#c2c4ce", "#f6f6f9");
196
+ r(c, x + 1, y + 7, 38, 1, "#b6b6c1");
197
+ [[3, P.folder], [9, P.binder2], [15, P.binder3], [21, P.book],
198
+ [27, P.folder]].forEach(([off, col]) => {
199
+ r(c, x + off, y + 2, 5, 8, col);
200
+ r(c, x + off, y + 2, 5, 1, "rgba(255,255,255,.4)");
201
+ });
202
+ papers(c, x + 33, y + 3);
203
+ }
204
+
205
+ /* a full cubicle: outlined partitions with faces, white desk with front */
206
+ function cubicle(c, s, accent, opts = {}) {
207
+ const dx = s.x - 26, dy = s.y + 6; // matches deskCollider
208
+ const py = s.y - 34;
209
+ // back partition: cap, cloth panel, face — fully outlined
210
+ r(c, dx - 5, py - 1, 62, 30, P.ink);
211
+ r(c, dx - 4, py, 60, 4, P.partTop);
212
+ r(c, dx - 4, py + 4, 60, 18, P.partFace);
213
+ r(c, dx - 2, py + 6, 56, 14, P.partCloth);
214
+ r(c, dx - 2, py + 6, 56, 1, "rgba(255,255,255,.35)");
215
+ r(c, dx - 4, py + 22, 60, 6, "#9da4b6"); // partition front face
216
+ // pinned note + identity accent stripe
217
+ r(c, dx + 4, py + 8, 6, 5, P.paper);
218
+ r(c, dx + 44, py + 8, 7, 5, accent);
219
+ r(c, dx + 44, py + 8, 7, 1, "rgba(255,255,255,.45)");
220
+ // side wings (outlined)
221
+ r(c, dx - 9, py + 3, 7, 58, P.ink);
222
+ r(c, dx - 8, py + 4, 5, 56, P.partFace);
223
+ r(c, dx - 8, py + 4, 5, 2, P.partTop);
224
+ r(c, dx + 54, py + 3, 7, 58, P.ink);
225
+ r(c, dx + 55, py + 4, 5, 56, P.partFace);
226
+ r(c, dx + 55, py + 4, 5, 2, P.partTop);
227
+ // desk: lit top + front face + outline + contact shadow
228
+ shade(c, dx - 2, dy + 31, 58);
229
+ box3d(c, dx, dy, 52, 20, 10, P.deskTop, P.deskFace, P.deskTopHi);
230
+ r(c, dx, dy + 26, 52, 2, P.deskFaceLo);
231
+ // desk gear
232
+ monitor(c, s.x, dy + 5, !opts.darkMonitor);
233
+ keyboard(c, s.x - 6, dy + 15);
234
+ papers(c, dx + 5, dy + 13);
235
+ deskLamp(c, dx + 41, dy + 6);
236
+ r(c, dx + 44, dy + 16, 5, 5, P.mug);
237
+ r(c, dx + 44, dy + 16, 5, 1, "#f2917f");
238
+ if (opts.phone) landline(c, dx + 3, dy + 3);
239
+ if (opts.books) { r(c, dx + 3, dy + 14, 4, 7, P.book);
240
+ r(c, dx + 8, dy + 15, 4, 6, P.folder); }
241
+ }
242
+
243
+ /* --------------------------------------------------------- the static scene */
244
+ function buildStatic() {
245
+ const cv = document.createElement("canvas");
246
+ cv.width = W; cv.height = H;
247
+ const c = cv.getContext("2d");
248
+
249
+ // ---- light tile floor with thin grid seams
250
+ for (let ty = 0; ty < H / 32; ty++)
251
+ for (let tx = 0; tx < W / 32; tx++)
252
+ r(c, tx * 32, ty * 32, 32, 32, (tx + ty) % 2 ? P.tileA : P.tileB);
253
+ c.globalAlpha = 0.85;
254
+ for (let x = 0; x <= W; x += 32) r(c, x, 0, 1, H, P.tileSeam);
255
+ for (let y = 0; y <= H; y += 32) r(c, 0, y, W, 1, P.tileSeam);
256
+ c.globalAlpha = 1;
257
+ // sunlight patches under the corridor windows
258
+ c.globalAlpha = 0.10;
259
+ r(c, 244, 30, 92, 66, "#fff3c4");
260
+ r(c, 340, 30, 92, 66, "#fff3c4");
261
+ c.globalAlpha = 1;
262
+
263
+ // ---- boardroom interior — warm meeting room
264
+ const br = ROOMS.boardroom;
265
+ for (let ty = 0; ty < br.h / 16; ty++)
266
+ for (let tx = 0; tx < br.w / 16; tx++)
267
+ r(c, br.x + tx * 16, br.y + ty * 16, 16, 16,
268
+ (tx + ty) % 2 ? P.meetFloorA : P.meetFloorB);
269
+ // north wall with real height (cap + face + outline)
270
+ r(c, br.x, br.y, br.w, 4, P.wallCap);
271
+ r(c, br.x, br.y + 4, br.w, 16, P.wallFace);
272
+ r(c, br.x, br.y + 18, br.w, 2, P.wallShade);
273
+ r(c, br.x, br.y + 20, br.w, 1, P.ink);
274
+ // area rug
275
+ r(c, br.x + 38, br.y + 52, 146, 74, "#b6543e");
276
+ r(c, br.x + 42, br.y + 56, 138, 66, "#c4664e");
277
+ // projector screen mounted on the wall face
278
+ r(c, br.x + 63, br.y + 5, 92, 27, P.ink);
279
+ r(c, br.x + 64, br.y + 6, 90, 25, "#5d5d68");
280
+ r(c, br.x + 67, br.y + 9, 84, 19, "#fdfdfe");
281
+ r(c, br.x + 67, br.y + 9, 84, 4, "#e8e8ee");
282
+ // big table: wood top + front face + outline
283
+ shade(c, br.x + 46, br.y + 119, 130);
284
+ box3d(c, br.x + 46, br.y + 56, 128, 50, 12, P.woodTop, P.woodFace,
285
+ P.woodTopHi);
286
+ papers(c, br.x + 70, br.y + 74); papers(c, br.x + 130, br.y + 86);
287
+ r(c, br.x + 101, br.y + 78, 16, 11, P.ink);
288
+ r(c, br.x + 102, br.y + 79, 14, 9, P.metalTop); // speakerphone
289
+ r(c, br.x + 107, br.y + 82, 4, 3, "#6e7180");
290
+ [[64, 44], [104, 44], [144, 44], [64, 128], [104, 128], [144, 128]]
291
+ .forEach(([x, y]) => chair(c, br.x + x, br.y + y));
292
+ plant(c, br.x + 16, br.y + 140);
293
+ briefcase(c, br.x + 196, br.y + 132);
294
+ // boardroom side walls + south wall with outside face
295
+ r(c, br.x, br.y, 6, br.h, P.wallCap);
296
+ r(c, br.x + 5, br.y, 1, br.h, P.ink);
297
+ r(c, br.x, br.y + br.h - 6, br.w, 6, P.wallCap);
298
+ r(c, br.x, br.y + br.h, br.w - 6, 10, P.wallFace); // face seen from hall
299
+ r(c, br.x, br.y + br.h + 8, br.w - 6, 2, P.wallShade);
300
+ r(c, br.x, br.y + br.h + 10, br.w - 6, 1, P.ink);
301
+ // right wall: glass top section + door gap (90..140) + cap below
302
+ r(c, br.x + br.w - 6, br.y, 6, 14, P.wallCap);
303
+ r(c, br.x + br.w - 6, br.y, 6, 1, P.ink);
304
+ r(c, br.x + br.w - 7, br.y + 14, 8, 70, P.glassFrame);
305
+ r(c, br.x + br.w - 5, br.y + 16, 4, 66, P.glass);
306
+ for (let i = 0; i < 4; i++)
307
+ r(c, br.x + br.w - 5, br.y + 18 + i * 17, 4, 1, P.glassLine);
308
+ r(c, br.x + br.w - 6, br.y + 84, 6, 6, P.wallCap);
309
+ r(c, br.x + br.w - 6, br.y + 140, 6, 30, P.wallCap);
310
+ r(c, br.x + br.w - 1, br.y, 1, br.h, P.ink);
311
+ // door frame
312
+ r(c, br.x + br.w - 7, br.y + 88, 8, 3, P.ink);
313
+ r(c, br.x + br.w - 7, br.y + 139, 8, 3, P.ink);
314
+ // BOARDROOM plaque on the wall face
315
+ r(c, br.x + 55, br.y + 5, 82, 13, P.ink);
316
+ r(c, br.x + 56, br.y + 6, 80, 11, "#fdfdfe");
317
+ c.font = "7px 'Press Start 2P'";
318
+ c.fillStyle = "#34343f";
319
+ c.fillText("BOARDROOM", br.x + 61, br.y + 15);
320
+
321
+ // ---- break room interior — warm wood planks
322
+ const bk = ROOMS.breakroom;
323
+ for (let py = 0; py < bk.h; py += 10) {
324
+ r(c, bk.x, bk.y + py, bk.w, 10,
325
+ (py / 10) % 2 ? P.woodFloorA : P.woodFloorB);
326
+ r(c, bk.x, bk.y + py, bk.w, 1, P.woodSeam);
327
+ }
328
+ // north wall face inside the break room
329
+ r(c, bk.x, bk.y, bk.w, 4, P.wallCap);
330
+ r(c, bk.x, bk.y + 4, bk.w, 14, P.wallFace);
331
+ r(c, bk.x, bk.y + 16, bk.w, 2, P.wallShade);
332
+ r(c, bk.x, bk.y + 18, bk.w, 1, P.ink);
333
+ // counter: lit top + face + outline
334
+ shade(c, bk.x + 6, bk.y + 39, 124);
335
+ box3d(c, bk.x + 6, bk.y + 10, 124, 18, 10, P.deskTop, P.deskFace,
336
+ P.deskTopHi);
337
+ r(c, bk.x + 16, bk.y + 14, 16, 10, P.ink);
338
+ r(c, bk.x + 17, bk.y + 15, 14, 8, "#a9adba"); // sink
339
+ r(c, bk.x + 19, bk.y + 17, 10, 4, "#8a8e9c");
340
+ r(c, bk.x + 44, bk.y + 14, 10, 8, P.mug); // mugs
341
+ r(c, bk.x + 44, bk.y + 14, 10, 2, "#f2917f");
342
+ r(c, bk.x + 58, bk.y + 16, 8, 6, P.folder);
343
+ papers(c, bk.x + 76, bk.y + 15);
344
+ // fridge: tall outlined block
345
+ shade(c, bk.x + 140, bk.y + 53, 28);
346
+ r(c, bk.x + 139, bk.y + 7, 30, 48, P.ink);
347
+ r(c, bk.x + 140, bk.y + 8, 28, 46, "#eceef2");
348
+ r(c, bk.x + 140, bk.y + 8, 28, 3, "#fafbfd");
349
+ r(c, bk.x + 140, bk.y + 26, 28, 2, "#c3c6cf");
350
+ r(c, bk.x + 162, bk.y + 14, 3, 8, "#9da0ab");
351
+ r(c, bk.x + 140, bk.y + 48, 28, 6, "#d4d6de"); // fridge base face
352
+ // vending machine: outlined, lit window
353
+ shade(c, bk.x + 112, bk.y + 99, 22);
354
+ r(c, bk.x + 111, bk.y + 63, 24, 38, P.ink);
355
+ r(c, bk.x + 112, bk.y + 64, 22, 36, "#d9534f");
356
+ r(c, bk.x + 112, bk.y + 64, 22, 3, "#e8736f");
357
+ r(c, bk.x + 115, bk.y + 70, 12, 20, "#23232e");
358
+ [[0, P.binder2], [6, P.folder], [12, "#5cc46e"]].forEach(([off, col]) => {
359
+ r(c, bk.x + 116, bk.y + 72 + off, 10, 3, col);
360
+ });
361
+ r(c, bk.x + 129, bk.y + 72, 3, 10, "#f2f2f5");
362
+ r(c, bk.x + 112, bk.y + 94, 22, 6, "#b23f3c"); // machine base face
363
+ // coffee machine on the counter end — the shrine
364
+ const cf = SPOTS.coffee;
365
+ shade(c, cf.x - 11, cf.y + 11, 24);
366
+ r(c, cf.x - 11, cf.y - 11, 24, 24, P.ink);
367
+ r(c, cf.x - 10, cf.y - 10, 22, 22, "#3a3a45");
368
+ r(c, cf.x - 10, cf.y - 10, 22, 3, "#52525e");
369
+ r(c, cf.x - 6, cf.y - 5, 14, 7, "#1d1d26");
370
+ r(c, cf.x - 4, cf.y + 4, 6, 5, P.mug);
371
+ r(c, cf.x + 5, cf.y - 3, 3, 2, "#7ddb6f");
372
+ // small table + chairs
373
+ shade(c, bk.x + 32, bk.y + 103, 48);
374
+ box3d(c, bk.x + 32, bk.y + 76, 48, 20, 8, P.woodTop, P.woodFace,
375
+ P.woodTopHi);
376
+ chair(c, bk.x + 20, bk.y + 84); chair(c, bk.x + 92, bk.y + 84);
377
+ r(c, bk.x + 48, bk.y + 82, 6, 6, P.mug);
378
+ // water cooler near the entrance gap
379
+ shade(c, bk.x + 56, bk.y + 132, 16, true);
380
+ r(c, bk.x + 56, bk.y + 111, 14, 24, P.ink);
381
+ r(c, bk.x + 57, bk.y + 116, 12, 15, "#dfe7f2");
382
+ r(c, bk.x + 59, bk.y + 112, 8, 6, "#bcd8f0");
383
+ r(c, bk.x + 57, bk.y + 128, 12, 3, P.metalFace);
384
+ // walls
385
+ r(c, bk.x, bk.y, 6, bk.h, P.wallCap);
386
+ r(c, bk.x + 5, bk.y, 1, bk.h, P.ink);
387
+ r(c, bk.x, bk.y + bk.h - 6, 50, 6, P.wallCap);
388
+ r(c, bk.x, bk.y + bk.h, 50, 10, P.wallFace); // south face, left segment
389
+ r(c, bk.x, bk.y + bk.h + 9, 50, 1, P.ink);
390
+ r(c, bk.x + 110, bk.y + bk.h - 6, 70, 6, P.wallCap);
391
+ r(c, bk.x + 110, bk.y + bk.h, 70, 10, P.wallFace);
392
+ r(c, bk.x + 110, bk.y + bk.h + 9, 70, 1, P.ink);
393
+ // BREAK RM plaque on the wall face
394
+ r(c, bk.x + 29, bk.y + 3, 74, 13, P.ink);
395
+ r(c, bk.x + 30, bk.y + 4, 72, 11, "#fdfdfe");
396
+ c.fillStyle = "#34343f";
397
+ c.fillText("BREAK RM", bk.x + 35, bk.y + 13);
398
+
399
+ // ---- outer walls: white face with cap + ink line + daylight windows
400
+ r(c, 0, 0, W, 4, P.wallCap);
401
+ r(c, 0, 4, W, 18, P.wallFace);
402
+ r(c, 0, 4, W, 2, "#ffffff");
403
+ r(c, 0, 20, W, 3, P.wallShade);
404
+ r(c, 0, 23, W, 1, P.ink);
405
+ r(c, 0, 24, W, 3, P.baseboard);
406
+ // corridor windows: daytime sky, soft clouds, light skyline
407
+ [[250, 76], [346, 76]].forEach(([wx, ww]) => {
408
+ r(c, wx - 4, 2, ww + 8, 24, P.ink);
409
+ r(c, wx - 3, 3, ww + 6, 22, P.glassFrame);
410
+ r(c, wx, 5, ww, 18, P.sky);
411
+ r(c, wx, 5, ww, 6, P.skyHi);
412
+ r(c, wx + 8, 8, 14, 3, "#ffffff");
413
+ r(c, wx + 40, 11, 18, 3, "#ffffff");
414
+ for (let i = 0; i < ww / 12; i++) {
415
+ const bh = 6 + ((i * 37) % 7);
416
+ r(c, wx + i * 12 + 1, 23 - bh, 9, bh, P.bldg);
417
+ if (i % 2 === 0) r(c, wx + i * 12 + 3, 25 - bh + 2, 2, 2, P.bldgLit);
418
+ if (i % 3 === 0) r(c, wx + i * 12 + 6, 25 - bh + 4, 2, 2, P.bldgLit2);
419
+ }
420
+ r(c, wx + ww / 2 - 1, 5, 2, 18, P.glassFrame); // mullion
421
+ });
422
+ // AC unit on the wall face
423
+ r(c, 25, 5, 36, 16, P.ink);
424
+ r(c, 26, 6, 34, 14, "#e6e6eb");
425
+ r(c, 26, 6, 34, 3, "#f6f6f9");
426
+ r(c, 30, 14, 26, 2, "#b6b6c1");
427
+ r(c, 30, 17, 26, 1, "#b6b6c1");
428
+ // shelf with binders on the corridor wall
429
+ shelfUnit(c, 380, 28);
430
+ // whiteboard with line graph on the corridor wall (reference back wall)
431
+ r(c, 564, 4, 60, 21, P.ink);
432
+ r(c, 565, 5, 58, 19, "#ffffff");
433
+ r(c, 565, 5, 58, 2, "#e8e8ee");
434
+ c.strokeStyle = "#d9534f"; c.lineWidth = 1;
435
+ c.beginPath(); c.moveTo(570, 19); c.lineTo(580, 12); c.lineTo(590, 16);
436
+ c.lineTo(602, 8); c.lineTo(616, 11); c.stroke();
437
+ c.strokeStyle = "#3aa3e0";
438
+ c.beginPath(); c.moveTo(570, 21); c.lineTo(585, 18); c.lineTo(600, 20);
439
+ c.lineTo(616, 15); c.stroke();
440
+ // side + bottom walls
441
+ r(c, 0, H - WALL, W, WALL, P.wallFace);
442
+ r(c, 0, H - WALL, W, 2, P.ink);
443
+ r(c, 0, 0, WALL, H, P.wallFace);
444
+ r(c, WALL - 1, 0, 1, H, P.ink);
445
+ r(c, W - WALL, 0, WALL, H, P.wallFace);
446
+ r(c, W - WALL, 0, 1, H, P.ink);
447
+ // window on the left wall (rain overlay is dynamic)
448
+ r(c, 0, 226, WALL, 78, P.ink);
449
+ r(c, 0, 228, WALL - 1, 74, P.glassFrame);
450
+ r(c, 1, 231, WALL - 3, 68, P.sky);
451
+ r(c, 1, 262, WALL - 3, 2, P.glassFrame);
452
+ // entrance: double door at the bottom
453
+ r(c, 299, H - WALL - 1, 62, WALL + 1, P.ink);
454
+ r(c, 300, H - WALL, 60, WALL, "#c2996a");
455
+ r(c, 300, H - WALL, 60, 2, "#dab987");
456
+ r(c, 329, H - WALL, 2, WALL, P.ink);
457
+ r(c, 306, H - 6, 4, 2, "#6b4d2a"); r(c, 350, H - 6, 4, 2, "#6b4d2a");
458
+ // EXIT sign
459
+ r(c, 313, H - 25, 34, 12, P.ink);
460
+ r(c, 314, H - 24, 32, 10, "#1f7a37");
461
+ c.fillStyle = "#d8ffe2";
462
+ c.font = "6px 'Press Start 2P'";
463
+ c.fillText("EXIT", 318, H - 16);
464
+ // welcome mat
465
+ r(c, 302, H - 34, 56, 14, "#b6543e");
466
+ r(c, 302, H - 34, 56, 1, "#8a3f2e"); r(c, 302, H - 21, 56, 1, "#8a3f2e");
467
+
468
+ // ---- cubicles
469
+ cubicle(c, SPOTS.playerDesk, "#5b5b68", { books: true });
470
+ cubicle(c, NPC_SPOTS.brad, "#ff5a3c", { phone: true });
471
+ cubicle(c, NPC_SPOTS.stacey, "#1fd9c4", { books: true });
472
+ cubicle(c, NPC_SPOTS.kevin, "#ffd23f");
473
+ cubicle(c, NPC_SPOTS.janet, "#b95cff", { books: true });
474
+ cubicle(c, NPC_SPOTS.derek, "#8fd4ff", { darkMonitor: true, phone: true });
475
+ // "YOU" tag under the player cubicle
476
+ c.fillStyle = P.label;
477
+ c.font = "6px 'Press Start 2P'";
478
+ c.fillText("YOU", SPOTS.playerDesk.x - 9, SPOTS.playerDesk.y + 52);
479
+
480
+ // janet's mood board on her partition
481
+ const j = NPC_SPOTS.janet;
482
+ r(c, j.x - 19, j.y - 31, 38, 18, P.ink);
483
+ r(c, j.x - 18, j.y - 30, 36, 16, "#ffffff");
484
+ [["#ff36c0", 0], ["#3aa3e0", 9], ["#5cc46e", 18], ["#e6b33c", 27]]
485
+ .forEach(([col, off]) => r(c, j.x - 15 + off, j.y - 27, 6, 6, col));
486
+ r(c, j.x - 15, j.y - 19, 26, 2, P.paperShadow);
487
+
488
+ // kevin's whiteboard (right wall beside his cubicle)
489
+ r(c, 605, 311, 26, 50, P.ink);
490
+ r(c, 606, 312, 24, 48, "#9a9aa8");
491
+ r(c, 609, 315, 18, 42, "#ffffff");
492
+ c.strokeStyle = "#d9534f"; c.lineWidth = 1;
493
+ c.beginPath(); c.moveTo(612, 350); c.lineTo(617, 326);
494
+ c.lineTo(621, 340); c.lineTo(625, 320); c.stroke();
495
+ c.fillStyle = "#3aa3e0"; c.fillText("?", 612, 324);
496
+
497
+ // derek: ancient beige tower + briefcase he has had since 2009
498
+ const d = NPC_SPOTS.derek;
499
+ r(c, d.x - 25, d.y + 9, 12, 14, P.ink);
500
+ r(c, d.x - 24, d.y + 10, 10, 12, "#d9d2b8");
501
+ r(c, d.x - 22, d.y + 12, 6, 2, "#bfb89e");
502
+ briefcase(c, d.x + 38, d.y + 24);
503
+
504
+ // ---- props along walls
505
+ // filing cabinets (left wall): outlined with drawer faces
506
+ [320, 360].forEach((y) => {
507
+ shade(c, WALL + 2, y + 31, 26);
508
+ r(c, WALL + 1, y - 1, 26, 34, P.ink);
509
+ r(c, WALL + 2, y, 24, 32, P.metalTop);
510
+ r(c, WALL + 2, y, 24, 3, P.metalHi);
511
+ r(c, WALL + 2, y + 26, 24, 6, P.metalFace);
512
+ r(c, WALL + 4, y + 7, 20, 2, P.metalFace);
513
+ r(c, WALL + 4, y + 17, 20, 2, P.metalFace);
514
+ r(c, WALL + 12, y + 4, 5, 2, P.metalHi);
515
+ r(c, WALL + 12, y + 14, 5, 2, P.metalHi);
516
+ });
517
+ papers(c, WALL + 8, 308);
518
+ briefcase(c, WALL + 30, 344);
519
+ // colorful poster on the left wall
520
+ r(c, WALL + 3, 179, 28, 22, P.ink);
521
+ r(c, WALL + 4, 180, 26, 20, "#ffffff");
522
+ r(c, WALL + 6, 182, 22, 16, "#2c2c38");
523
+ [["#ff36c0", 0, 0], ["#3aa3e0", 11, 0], ["#5cc46e", 0, 8],
524
+ ["#e6b33c", 11, 8]].forEach(([col, ox, oy]) => {
525
+ r(c, WALL + 7 + ox, 183 + oy, 9, 7, col);
526
+ });
527
+ // copier / printer near reception: outlined block with tray
528
+ const pr = SPOTS.printer;
529
+ shade(c, pr.x - 13, pr.y + 9, 30);
530
+ r(c, pr.x - 14, pr.y - 9, 30, 20, P.ink);
531
+ r(c, pr.x - 13, pr.y - 8, 28, 14, "#e6e6eb");
532
+ r(c, pr.x - 13, pr.y - 8, 28, 3, "#f6f6f9");
533
+ r(c, pr.x - 13, pr.y + 6, 28, 4, "#c5c7d1"); // front face
534
+ r(c, pr.x - 8, pr.y - 12, 18, 5, "#cfcfd8");
535
+ r(c, pr.x - 8, pr.y - 12, 18, 1, P.ink);
536
+ r(c, pr.x - 5, pr.y - 2, 12, 4, "#3a3a45");
537
+ r(c, pr.x + 9, pr.y - 5, 3, 2, "#5cc46e");
538
+ // reception desk: wood with front face
539
+ shade(c, SPOTS.reception.x - 30, SPOTS.reception.y + 9, 60);
540
+ box3d(c, SPOTS.reception.x - 30, SPOTS.reception.y - 8, 60, 14, 8,
541
+ P.woodTop, P.woodFace, P.woodTopHi);
542
+ monitor(c, SPOTS.reception.x - 14, SPOTS.reception.y - 2);
543
+ landline(c, SPOTS.reception.x + 6, SPOTS.reception.y - 4);
544
+ r(c, SPOTS.reception.x + 20, SPOTS.reception.y - 4, 8, 6, P.leaf);
545
+ // big plant in the corner
546
+ plant(c, SPOTS.plant.x, SPOTS.plant.y, true);
547
+ plant(c, 248, 188);
548
+ plant(c, 432, 188);
549
+ // wall art between windows
550
+ r(c, 319, 6, 24, 18, P.ink);
551
+ r(c, 320, 7, 22, 16, "#ffffff");
552
+ r(c, 322, 9, 18, 12, "#aedcf2");
553
+ r(c, 324, 15, 6, 3, "#5cc46e"); r(c, 332, 11, 5, 5, "#e6b33c");
554
+ // scattered papers on the floor near brad (of course near brad)
555
+ [[352, 250, -20], [368, 262, 35], [340, 270, 10]].forEach(([x, y, a]) => {
556
+ c.save(); c.translate(x, y); c.rotate((a * Math.PI) / 180);
557
+ r(c, -4, -3, 8, 6, P.paper);
558
+ r(c, -4, 2, 8, 1, P.paperShadow);
559
+ c.restore();
560
+ });
561
+ // inbox tray: outlined with paper inside
562
+ const ib = SPOTS.inbox;
563
+ r(c, ib.x - 10, ib.y - 6, 22, 13, P.ink);
564
+ r(c, ib.x - 9, ib.y - 5, 20, 11, P.metalFace);
565
+ r(c, ib.x - 8, ib.y - 4, 18, 9, P.metalTop);
566
+ r(c, ib.x - 6, ib.y - 2, 14, 5, "#8a8e9c");
567
+ r(c, ib.x - 6, ib.y - 2, 14, 1, P.paper);
568
+
569
+ return cv;
570
+ }
571
+
572
+ /* --------------------------------------------------------- per-frame layer */
573
+ const css = (name) =>
574
+ getComputedStyle(document.documentElement).getPropertyValue(name).trim();
575
+
576
+ export function drawFloor(ctx, opts = {}) {
577
+ if (!staticScene) staticScene = buildStatic();
578
+ ctx.drawImage(staticScene, 0, 0);
579
+
580
+ if (opts.gloomy) { r(ctx, 0, 0, W, H, "rgba(54,60,92,.30)"); }
581
+
582
+ const br = ROOMS.boardroom;
583
+ // boardroom door (dynamic glow during presentation trigger)
584
+ if (opts.doorGlow) {
585
+ const pulse = Math.floor(Date.now() / 280) % 2;
586
+ r(ctx, br.x + br.w - 6, br.y + 90, 6, 50, pulse ? "#ffc73b" : "#d9a82f");
587
+ ctx.globalAlpha = pulse ? 0.35 : 0.2;
588
+ r(ctx, br.x + br.w - 16, br.y + 82, 26, 66, "#ffc73b");
589
+ ctx.globalAlpha = 1;
590
+ } else {
591
+ r(ctx, br.x + br.w - 6, br.y + 90, 6, 50, "#c2996a");
592
+ r(ctx, br.x + br.w - 6, br.y + 90, 2, 50, "#dab987");
593
+ r(ctx, br.x + br.w - 4, br.y + 112, 2, 6, "#6b4d2a"); // handle
594
+ }
595
+
596
+ // left-wall window rain (morale < 20 manifests as weather. nobody asks why)
597
+ if (opts.gloomy) {
598
+ if (rainDrops.length === 0)
599
+ rainDrops = Array.from({ length: 7 }, () =>
600
+ ({ x: 1 + Math.random() * 6, y: 232 + Math.random() * 64 }));
601
+ ctx.fillStyle = "#5a8ec4";
602
+ rainDrops.forEach((d) => {
603
+ ctx.fillRect(d.x, d.y, 1, 4);
604
+ d.y += 2; if (d.y > 296) d.y = 232;
605
+ });
606
+ // rain on the skyline windows too
607
+ ctx.globalAlpha = 0.5;
608
+ for (let i = 0; i < 10; i++) {
609
+ const rx = 252 + ((i * 53 + Math.floor(Date.now() / 90) * 7) % 168);
610
+ ctx.fillRect(rx, 6 + (i * 31) % 12, 1, 4);
611
+ }
612
+ ctx.globalAlpha = 1;
613
+ }
614
+
615
+ // wall clock with moving hands
616
+ const ck = SPOTS.clock;
617
+ r(ctx, ck.x - 9, ck.y - 9, 18, 18, "#3a3d49");
618
+ r(ctx, ck.x - 7, ck.y - 7, 14, 14, "#ffffff");
619
+ r(ctx, ck.x - 7, ck.y - 7, 14, 2, "#e8e8ee");
620
+ ctx.fillStyle = "#2c2c38";
621
+ const sec = Math.floor(Date.now() / 1000) % 60;
622
+ const ang = (sec / 60) * Math.PI * 2 - Math.PI / 2;
623
+ ctx.fillRect(ck.x, ck.y, Math.max(1, Math.cos(ang) * 4), 1);
624
+ ctx.fillRect(ck.x, ck.y - 3, 1, 3);
625
+
626
+ // monitors flicker faintly (alive, barely)
627
+ if (Math.floor(Date.now() / 800) % 5 === 0) {
628
+ ctx.globalAlpha = 0.15;
629
+ for (const s of [SPOTS.playerDesk, ...Object.values(NPC_SPOTS)])
630
+ r(ctx, s.x - 6, s.y + 7, 12, 8, "#7fb2ec");
631
+ ctx.globalAlpha = 1;
632
+ }
633
+
634
+ // printer shake during jam events
635
+ if (opts.printerShake) {
636
+ const pj = Math.sin(Date.now() / 30) * 2;
637
+ const pr = SPOTS.printer;
638
+ r(ctx, pr.x - 13 + pj, pr.y - 8, 28, 17, "#e6e6eb");
639
+ r(ctx, pr.x - 8 + pj, pr.y - 12, 18, 5, "#cfcfd8");
640
+ r(ctx, pr.x - 2 + pj, pr.y - 14, 10, 6, "#ffffff");
641
+ }
642
+
643
+ // desk phone on the desk surface (vibrates for client emergencies)
644
+ const pd = SPOTS.playerDesk;
645
+ const ph = opts.phoneRing ? Math.sin(Date.now() / 25) * 2 : 0;
646
+ r(ctx, pd.x + 9 + ph, pd.y + 12, 16, 11, "#3a3d49");
647
+ r(ctx, pd.x + 10 + ph, pd.y + 13, 14, 9, "#b9bcc8");
648
+ r(ctx, pd.x + 11 + ph, pd.y + 14, 12, 2, "#d4d6de");
649
+ r(ctx, pd.x + 12 + ph, pd.y + 18, 8, 2, "#7d8290");
650
+ if (opts.phoneRing && Math.floor(Date.now() / 200) % 2)
651
+ r(ctx, pd.x + 14, pd.y + 6, 6, 4, css("--bds-neon-cyan") || "#2ff0ff");
652
+
653
+ // inbox tray lights up for inbox/HR events
654
+ if (opts.inboxLit) {
655
+ const ib = SPOTS.inbox;
656
+ const lit = Math.floor(Date.now() / 300) % 2;
657
+ r(ctx, ib.x - 6, ib.y - 2, 14, 5, lit ? "#8edb3a" : "#6fae2c");
658
+ ctx.globalAlpha = 0.3;
659
+ r(ctx, ib.x - 12, ib.y - 8, 26, 17, "#8edb3a");
660
+ ctx.globalAlpha = 1;
661
+ }
662
+ }
static/js/npcs.js ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // the five underlings: floor sprites, mood-driven idles, bubbles, prompts
2
+ import { NPC_SPOTS } from "./map.js";
3
+ import { makeChar } from "./sprites.js";
4
+ import { zzz } from "./particles.js";
5
+
6
+ // mood (server npc_moods string) → topSprite pose
7
+ const MOOD_POSE = {
8
+ normal: "idle", happy: "idle", energized: "idle", grateful: "idle",
9
+ smug: "lean", suspicious: "lean", confused: "idle",
10
+ sad: "slump", tired: "slump", devastated: "headdown", sheepish: "slump",
11
+ angry: "shake", hiding: "slump", crying: "slump", sleeping: "headdown",
12
+ };
13
+
14
+ export function createNpcs(worldEl) {
15
+ const npcs = {};
16
+ for (const [id, spot] of Object.entries(NPC_SPOTS)) {
17
+ // desks/cubicles are part of the canvas scene now (map.js)
18
+ const opts = { facing: "down", pose: "idle" };
19
+ npcs[id] = makeChar(worldEl, id, spot.x, spot.y, opts);
20
+ npcs[id].bubble = null;
21
+ npcs[id].mood = "normal";
22
+ }
23
+ // Derek's default idle is one static frame. Intentional. Slightly unsettling.
24
+ npcs.derek.setPose("still");
25
+
26
+ // occasional ZZZ when Derek is left alone long enough
27
+ setInterval(() => {
28
+ if (npcs.derek.mood === "normal" && Math.random() < 0.3)
29
+ zzz(NPC_SPOTS.derek.x + 8, NPC_SPOTS.derek.y - 36);
30
+ }, 6000);
31
+
32
+ return {
33
+ npcs,
34
+ applyMoods(moods) {
35
+ for (const [id, mood] of Object.entries(moods || {})) {
36
+ const npc = npcs[id];
37
+ if (!npc || npc.mood === mood) continue;
38
+ npc.mood = mood;
39
+ if (id === "derek" && (mood === "normal" || mood === "happy"))
40
+ npc.setPose("still");
41
+ else npc.setPose(MOOD_POSE[mood] || "idle");
42
+ }
43
+ },
44
+ showBubble(id, kind = "danger") {
45
+ this.clearBubbles();
46
+ const npc = npcs[id];
47
+ const el = document.createElement("div");
48
+ el.className = "bubble" + (kind === "amber" ? " amber" : "") +
49
+ (kind === "heart" ? " heart" : "");
50
+ el.textContent = kind === "amber" ? "?" : kind === "heart" ? "♥" : "!";
51
+ el.style.left = `${npc.x}px`;
52
+ el.style.top = `${npc.y - 52}px`;
53
+ npc.root.parentElement.appendChild(el);
54
+ npc.bubble = el;
55
+ if (kind !== "heart") npc.setPose("shake");
56
+ setTimeout(() => { if (npc.bubble === el) npc.setPose(
57
+ MOOD_POSE[npc.mood] || "idle"); }, 600);
58
+ },
59
+ clearBubbles() {
60
+ for (const npc of Object.values(npcs))
61
+ if (npc.bubble) { npc.bubble.remove(); npc.bubble = null; }
62
+ },
63
+ freezeAll(v) {
64
+ for (const npc of Object.values(npcs)) npc.setDim(false), npc.root
65
+ .style.filter = v ? "brightness(.8)" : "";
66
+ },
67
+ // ambient speech bubble above an NPC; queued so eavesdrop alternates
68
+ sayBubble(id, text, ms = 4200) {
69
+ const npc = npcs[id];
70
+ if (!npc) return;
71
+ const el = document.createElement("div");
72
+ el.className = "say-bubble";
73
+ el.textContent = text;
74
+ el.style.left = `${npc.x}px`;
75
+ el.style.top = `${npc.y - 58}px`;
76
+ npc.root.parentElement.appendChild(el);
77
+ setTimeout(() => el.classList.add("fading"), ms - 600);
78
+ setTimeout(() => el.remove(), ms);
79
+ },
80
+ saySequence(lines, gap = 3400) {
81
+ lines.forEach((entry, i) => {
82
+ setTimeout(() => this.sayBubble(entry.speaker, entry.line, gap - 200),
83
+ i * gap);
84
+ });
85
+ },
86
+ };
87
+ }
static/js/office34.js ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ office34.js — 3/4 top-down ("RPG / Stardew") world engine
3
+ Brad Did Something — WARMLIGHT direction (plan of record).
4
+
5
+ Orthographic projection: every object shows its TOP and its
6
+ FRONT face simultaneously. Axis-aligned (not isometric).
7
+ Daylight, warm-neutral palette. No CRT.
8
+
9
+ Vanilla JS. Exposes window.Office34:
10
+ • render(rootEl, theme) — 560x384 style vignette (3 themes)
11
+ • renderCanonical(rootEl, theme) — 640x400 named game floor
12
+ • primitives: person, station, plant, printer, cooler, papers,
13
+ table, couch, fridge, door, whiteboard, room, wallTop
14
+ • THEMES.warmlight (+ coolopen, cozyden for exploration)
15
+ ============================================================ */
16
+ (function () {
17
+ /* ---- inject shared keyframes once ---- */
18
+ if (!document.getElementById("o34-style")) {
19
+ const s = document.createElement("style");
20
+ s.id = "o34-style";
21
+ s.textContent = `
22
+ .o34-root, .o34-root * { box-sizing: border-box; image-rendering: pixelated; }
23
+ @media (prefers-reduced-motion: no-preference) {
24
+ .o34-bob { animation: o34bob 2.6s steps(2) infinite; }
25
+ .o34-bob2 { animation: o34bob 2.1s steps(2) infinite; }
26
+ .o34-blink { animation: o34blink 1s steps(2) infinite; }
27
+ .o34-spin { animation: o34spin 6s linear infinite; }
28
+ .o34-door { animation: o34door 1.8s ease-in-out infinite; }
29
+ }
30
+ @keyframes o34bob { 50% { transform: translateY(-2px); } }
31
+ @keyframes o34blink{ 50% { opacity: .2; } }
32
+ @keyframes o34spin { to { transform: rotate(360deg); } }
33
+ @keyframes o34door {
34
+ 0%,100% { box-shadow: 0 0 7px 1px rgba(255,207,107,.55); }
35
+ 50% { box-shadow: 0 0 18px 5px rgba(255,207,107,.95); }
36
+ }
37
+ `;
38
+ document.head.appendChild(s);
39
+ }
40
+
41
+ /* ---------- tiny string-builder helpers ---------- */
42
+ function rect(x, y, w, h, bg, extra) {
43
+ return `<i style="position:absolute;left:${x}px;top:${y}px;width:${w}px;height:${h}px;background:${bg};${extra || ""}"></i>`;
44
+ }
45
+ function grp(x, y, w, h, z, inner, extra) {
46
+ return `<div style="position:absolute;left:${x}px;top:${y}px;width:${w}px;height:${h}px;z-index:${z};${extra || ""}">${inner}</div>`;
47
+ }
48
+ const out = (ink) => `box-shadow:0 0 0 2px ${ink};`;
49
+
50
+ /* =========================================================
51
+ FRONT-FACING PIXEL PERSON (naturalistic, ~30x42 box)
52
+ ========================================================= */
53
+ function person(p, ink) {
54
+ const skin = p.skin, hair = p.hair, shirt = p.shirt, pants = p.pants || "#2c2b3a";
55
+ let h = "";
56
+ h += rect(8, 30, 6, 9, pants, out(ink));
57
+ h += rect(16, 30, 6, 9, pants, out(ink));
58
+ h += rect(7, 37, 8, 3, "#26242f", "");
59
+ h += rect(15, 37, 8, 3, "#26242f", "");
60
+ h += rect(2, 17, 5, 12, shirt, out(ink));
61
+ h += rect(23, 17, 5, 12, shirt, out(ink));
62
+ h += rect(2, 27, 5, 4, skin, "");
63
+ h += rect(23, 27, 5, 4, skin, "");
64
+ h += rect(6, 16, 18, 16, shirt, out(ink) + "border-radius:5px 5px 2px 2px;");
65
+ if (p.tie) h += rect(14, 18, 3, 11, p.tie, "");
66
+ h += rect(13, 13, 4, 5, skin, "");
67
+ h += rect(11, 16, 8, 3, "rgba(0,0,0,.18)", "border-radius:0 0 50% 50%;");
68
+ h += rect(7, 0, 16, 15, skin, out(ink) + "border-radius:46% 46% 44% 44%;z-index:2;");
69
+ if (p.style === "bald") {
70
+ h += rect(9, -1, 12, 5, hair, out(ink) + "border-radius:50% 50% 12% 12%;z-index:3;");
71
+ } else if (p.style === "long") {
72
+ h += rect(5, 1, 4, 16, hair, out(ink) + "border-radius:50% 20% 30% 60%;z-index:1;");
73
+ h += rect(21, 1, 4, 16, hair, out(ink) + "border-radius:20% 50% 60% 30%;z-index:1;");
74
+ h += rect(6, -2, 18, 8, hair, out(ink) + "border-radius:55% 55% 30% 30%;z-index:3;");
75
+ if (p.streak) h += rect(20, 0, 4, 13, p.streak, "z-index:4;border-radius:30% 50% 60% 30%;");
76
+ } else if (p.style === "messy") {
77
+ h += rect(5, -3, 20, 9, hair, out(ink) + "border-radius:60% 50% 40% 40% / 70% 65% 30% 30%;z-index:3;");
78
+ } else {
79
+ h += rect(6, -2, 18, 8, hair, out(ink) + "border-radius:55% 55% 25% 35%;z-index:3;");
80
+ }
81
+ h += rect(11, 6, 2, 3, ink, "z-index:4;border-radius:1px;");
82
+ h += rect(17, 6, 2, 3, ink, "z-index:4;border-radius:1px;");
83
+ if (p.glasses) {
84
+ h += rect(9, 5, 5, 5, "transparent", "z-index:5;border:2px solid " + ink + ";border-radius:2px;");
85
+ h += rect(16, 5, 5, 5, "transparent", "z-index:5;border:2px solid " + ink + ";border-radius:2px;");
86
+ h += rect(14, 7, 2, 2, ink, "z-index:5;");
87
+ }
88
+ if (p.sweat) h += rect(20, 4, 3, 4, "#7fc6e8", "z-index:6;border-radius:60% 60% 50% 50%;box-shadow:0 0 4px #7fc6e8;");
89
+ h += rect(13, 11, 4, 2, "rgba(120,40,30,.55)", "z-index:4;border-radius:2px;");
90
+ return h;
91
+ }
92
+
93
+ /* =========================================================
94
+ DESK STATION (top + front + monitor + chair + character)
95
+ opts: {x,y, screen, char, name, tone, papers, alert,
96
+ still, lean, ring}
97
+ ========================================================= */
98
+ function station(o, T) {
99
+ const { x, y } = o;
100
+ const dW = 72, dTopH = 26, dFrontH = 9;
101
+ const z = y + 200;
102
+ let h = "";
103
+
104
+ // ring (player spotlight) drawn under everything
105
+ let pre = "";
106
+ if (o.ring) {
107
+ pre = `<div class="o34-spin" style="position:absolute;left:${dW / 2 - 22}px;top:${dTopH + dFrontH + 14}px;width:44px;height:44px;border:2px dashed rgba(255,255,255,.6);border-radius:50%;z-index:0;"></div>`;
108
+ }
109
+
110
+ // chair tucked under desk
111
+ const chairY = dTopH + dFrontH - 4;
112
+ h += `<div style="position:absolute;left:${dW / 2 - 15}px;top:${chairY}px;width:30px;height:30px;z-index:1;">
113
+ ${rect(3, 8, 24, 18, T.chair, out(T.ink) + "border-radius:8px 8px 4px 4px;")}
114
+ ${rect(6, 2, 18, 12, T.chair2, out(T.ink) + "border-radius:9px 9px 3px 3px;")}
115
+ ${rect(12, 24, 6, 6, "#22232b", "")}
116
+ </div>`;
117
+
118
+ // desk top + front face
119
+ h += rect(0, 0, dW, dTopH, T.deskTop, out(T.ink) + "border-radius:2px;");
120
+ h += rect(2, 2, dW - 4, 4, "rgba(255,255,255,.16)", "border-radius:2px;");
121
+ h += rect(0, dTopH - 2, dW, dFrontH, T.deskFront, out(T.ink));
122
+ h += rect(0, dTopH - 2, dW, 2, "rgba(0,0,0,.25)", "");
123
+
124
+ // monitor standing at back, facing viewer
125
+ const mW = 26, mH = 19, mx = dW / 2 - mW / 2;
126
+ h += rect(dW / 2 - 5, -3, 10, 7, "#3a3d49", out(T.ink));
127
+ h += rect(mx, -16, mW, mH, "#2b2e3a", out(T.ink) + "border-radius:2px;");
128
+ const scr = o.screen || "lines";
129
+ if (scr !== "off") {
130
+ h += rect(mx + 3, -13, mW - 6, mH - 6, T.screen, "border-radius:1px;box-shadow:0 0 6px rgba(110,200,255,.35);");
131
+ if (scr === "chart") {
132
+ h += rect(mx + 5, -6, 3, 4, "#ffd56b", "");
133
+ h += rect(mx + 9, -8, 3, 6, "#6fd0ff", "");
134
+ h += rect(mx + 13, -5, 3, 3, "#ff8e6b", "");
135
+ h += rect(mx + 17, -9, 3, 7, "#8fe39a", "");
136
+ } else {
137
+ h += rect(mx + 5, -11, mW - 12, 2, "#9fe0ff", "");
138
+ h += rect(mx + 5, -8, mW - 16, 2, "#cfeeff", "");
139
+ h += rect(mx + 5, -5, mW - 10, 2, "#7fc8ff", "");
140
+ }
141
+ } else {
142
+ h += rect(mx + 3, -13, mW - 6, mH - 6, "#11131c", "border-radius:1px;");
143
+ }
144
+ // keyboard + mouse
145
+ h += rect(dW / 2 - 13, dTopH - 9, 26, 6, "#dcdae2", out(T.ink) + "border-radius:1px;");
146
+ h += rect(dW / 2 + 16, dTopH - 8, 5, 4, "#dcdae2", out(T.ink) + "border-radius:50%;");
147
+ // mug
148
+ h += rect(8, 6, 6, 6, o.tone || "#c96a4a", out(T.ink) + "border-radius:1px 1px 2px 2px;");
149
+ // papers on desk
150
+ if (o.papers) {
151
+ h += rect(dW - 20, 4, 11, 9, "#f3f0e6", out(T.ink) + "transform:rotate(-8deg);");
152
+ h += rect(dW - 16, 7, 11, 9, "#fbf8ee", out(T.ink) + "transform:rotate(6deg);");
153
+ }
154
+
155
+ // character standing in front, facing viewer
156
+ let charH = "";
157
+ if (o.char) {
158
+ const cls = o.still ? "" : "o34-bob";
159
+ const lean = o.lean ? "transform:rotate(-7deg);transform-origin:50% 100%;" : "";
160
+ charH = `<div class="${cls}" style="position:absolute;left:${dW / 2 - 15}px;top:${dTopH + dFrontH + 6}px;width:30px;height:42px;z-index:3;${lean}">
161
+ ${person(o.char, T.ink)}
162
+ </div>`;
163
+ }
164
+ if (o.alert) {
165
+ charH += `<div class="o34-blink" style="position:absolute;left:${dW / 2 + 12}px;top:-30px;width:18px;height:18px;z-index:6;background:#fff;border:2px solid ${T.ink};border-radius:4px;display:flex;align-items:center;justify-content:center;font-family:var(--font-display,monospace);font-size:11px;color:#d8402a;">!</div>`;
166
+ }
167
+ const tagY = dTopH + dFrontH + 52;
168
+ const tag = `<div style="position:absolute;left:-8px;top:${tagY}px;width:${dW + 16}px;text-align:center;font-family:var(--font-display,monospace);font-size:7px;letter-spacing:1px;color:${o.tone || "#fff"};text-shadow:1px 1px 0 ${T.ink};z-index:7;">${o.name || ""}</div>`;
169
+
170
+ return grp(x, y, dW, tagY + 14, z, pre + h + charH + tag);
171
+ }
172
+
173
+ /* =========================================================
174
+ PROPS
175
+ ========================================================= */
176
+ function plant(x, y, T, big) {
177
+ const s = big ? 1.25 : 1;
178
+ const w = Math.round(26 * s), potH = Math.round(12 * s);
179
+ let h = "";
180
+ h += rect(0, 18 * s, w, potH, T.pot, out(T.ink) + "border-radius:2px 2px 4px 4px;");
181
+ h += rect(0, 18 * s, w, 3, "rgba(255,255,255,.15)", "");
182
+ h += rect(2, 0, w - 4, Math.round(22 * s), T.leaf, out(T.ink) + "border-radius:60% 60% 40% 40%;");
183
+ h += rect(5, 3, 8, 10, T.leaf2, "border-radius:60% 30% 50% 40%;");
184
+ h += rect(w - 13, 5, 8, 9, T.leaf2, "border-radius:30% 60% 40% 50%;");
185
+ return grp(x, y, w, Math.round(30 * s), y + 200, h);
186
+ }
187
+ function printer(x, y, T) {
188
+ let h = "";
189
+ h += rect(0, 6, 34, 22, "#cfccc4", out(T.ink) + "border-radius:2px;");
190
+ h += rect(0, 6, 34, 4, "rgba(255,255,255,.2)", "");
191
+ h += rect(4, 0, 26, 8, "#b7b4ab", out(T.ink) + "border-radius:2px 2px 0 0;");
192
+ h += rect(6, 24, 22, 7, "#e9e6dc", out(T.ink));
193
+ h += rect(9, 26, 16, 9, "#fbf9f1", out(T.ink) + "transform:rotate(2deg);");
194
+ h += rect(24, 10, 4, 4, "#7fe39a", "border-radius:50%;box-shadow:0 0 4px #7fe39a;");
195
+ return grp(x, y, 34, 34, y + 200, h);
196
+ }
197
+ function cooler(x, y, T) {
198
+ let h = "";
199
+ h += rect(2, 14, 20, 22, "#e8ecef", out(T.ink) + "border-radius:2px;");
200
+ h += rect(4, 0, 16, 16, "#7fb9e0", out(T.ink) + "border-radius:6px 6px 2px 2px;");
201
+ h += rect(6, 2, 12, 8, "#a9d6f2", "border-radius:5px 5px 1px 1px;");
202
+ h += rect(7, 22, 10, 4, "#5a6a78", "");
203
+ return grp(x, y, 24, 38, y + 200, h);
204
+ }
205
+ function papers(x, y, T, n) {
206
+ let h = "";
207
+ for (let i = 0; i < (n || 4); i++) {
208
+ const px = (i * 13) % 40, py = ((i * 9) % 18);
209
+ h += rect(px, py, 11, 9, i % 2 ? "#fbf8ee" : "#eceadf", out(T.ink) + `transform:rotate(${(i % 2 ? 1 : -1) * (6 + i * 3)}deg);`);
210
+ }
211
+ return grp(x, y, 52, 30, y + 60, h);
212
+ }
213
+ // boardroom meeting table (3/4: top + front), with little chairs
214
+ function table(x, y, w, hgt, T) {
215
+ let h = "";
216
+ // chairs along top + bottom edges
217
+ let chairs = "";
218
+ const cn = Math.max(2, Math.round(w / 30));
219
+ for (let i = 0; i < cn; i++) {
220
+ const cx = 8 + i * ((w - 16) / (cn - 1)) - 7;
221
+ chairs += rect(cx, -10, 14, 12, T.chair, out(T.ink) + "border-radius:6px 6px 2px 2px;z-index:0;");
222
+ chairs += rect(cx, hgt + 2, 14, 11, T.chair2, out(T.ink) + "border-radius:3px 3px 6px 6px;z-index:0;");
223
+ }
224
+ h += chairs;
225
+ h += rect(0, 0, w, hgt, "#6b4a2e", out(T.ink) + "border-radius:4px;"); // top
226
+ h += rect(3, 3, w - 6, 5, "rgba(255,255,255,.14)", "border-radius:3px;");
227
+ h += rect(0, hgt - 2, w, 8, "#49301a", out(T.ink)); // front
228
+ // a couple of docs + a mug on the table
229
+ h += rect(w * 0.3, hgt * 0.3, 12, 9, "#fbf8ee", out(T.ink) + "transform:rotate(-6deg);");
230
+ h += rect(w * 0.58, hgt * 0.4, 6, 6, "#c96a4a", out(T.ink) + "border-radius:1px;");
231
+ return grp(x, y, w, hgt + 14, y + 100, h);
232
+ }
233
+ function couch(x, y, T) {
234
+ let h = "";
235
+ h += rect(0, 0, 56, 12, "#5f7384", out(T.ink) + "border-radius:6px 6px 2px 2px;"); // back
236
+ h += rect(0, 10, 56, 18, "#6d8294", out(T.ink) + "border-radius:2px 2px 4px 4px;"); // seat
237
+ h += rect(0, 8, 9, 18, "#566a7b", out(T.ink) + "border-radius:4px 2px 2px 4px;"); // arm L
238
+ h += rect(47, 8, 9, 18, "#566a7b", out(T.ink) + "border-radius:2px 4px 4px 2px;"); // arm R
239
+ h += rect(12, 13, 14, 11, "#7d92a3", "border-radius:3px;");
240
+ h += rect(30, 13, 14, 11, "#7d92a3", "border-radius:3px;");
241
+ return grp(x, y, 56, 34, y + 200, h);
242
+ }
243
+ function fridge(x, y, T) {
244
+ let h = "";
245
+ h += rect(0, 0, 26, 40, "#e2e5e9", out(T.ink) + "border-radius:3px;");
246
+ h += rect(0, 0, 26, 5, "rgba(255,255,255,.35)", "border-radius:3px 3px 0 0;");
247
+ h += rect(0, 18, 26, 2, T.ink, ""); // door split
248
+ h += rect(20, 6, 3, 9, "#9aa0a8", out(T.ink)); // handle top
249
+ h += rect(20, 24, 3, 9, "#9aa0a8", out(T.ink)); // handle bottom
250
+ return grp(x, y, 26, 44, y + 200, h);
251
+ }
252
+ // glowing amber boardroom door (animation drives the box-shadow glow,
253
+ // so the hard edge is a border, not a box-shadow)
254
+ function door(x, y, T) {
255
+ return `<div class="o34-door" style="position:absolute;left:${x}px;top:${y}px;width:38px;height:12px;z-index:${y + 250};background:linear-gradient(180deg,#ffe79a,#f3b53e 60%,#d99c1e);border:2px solid #8a5a12;"></div>`;
256
+ }
257
+ function whiteboard(x, y, T) {
258
+ let h = "";
259
+ h += rect(0, 0, 64, 34, "#f4f3ec", out(T.ink) + "border-radius:1px;");
260
+ h += rect(6, 7, 44, 2, "#d8402a", "");
261
+ h += rect(6, 14, 30, 2, "#3a6ea5", "");
262
+ h += rect(6, 21, 38, 2, "#2f9a93", "");
263
+ h += rect(6, 28, 24, 2, "#c79a3a", "");
264
+ return grp(x, y, 64, 34, 2, h);
265
+ }
266
+ // a floor-tinted partitioned room patch (top + side low walls, label on floor)
267
+ function room(x, y, w, hgt, label, T, opts) {
268
+ opts = opts || {};
269
+ const floor = opts.wood ? T.wood : T.roomFloor;
270
+ const seam = opts.wood ? T.woodSeam : T.seam;
271
+ let h = "";
272
+ // floor
273
+ h += `<i style="position:absolute;left:0;top:0;width:${w}px;height:${hgt}px;background:${floor};
274
+ background-image:linear-gradient(90deg, ${seam} 2px, transparent 2px), linear-gradient(0deg, ${seam} 2px, transparent 2px);
275
+ background-size:${opts.wood ? "16px 28px" : T.tile + "px " + T.tile + "px"};box-shadow:inset 0 0 16px rgba(0,0,0,.18);"></i>`;
276
+ // partition: top + left + right low walls (front faces)
277
+ h += rect(0, 0, w, 7, T.panelTop, out(T.ink));
278
+ h += rect(0, 0, 6, hgt, T.panelTop, out(T.ink));
279
+ h += rect(w - 6, 0, 6, hgt, T.panelTop, out(T.ink));
280
+ // label
281
+ h += `<span style="position:absolute;left:0;top:11px;width:${w}px;text-align:center;font-family:var(--font-display,monospace);font-size:7px;letter-spacing:1px;color:${T.ink};opacity:.55;">${label}</span>`;
282
+ return grp(x, y, w, hgt, y + 1, h);
283
+ }
284
+ function wallTop(W, T) {
285
+ let h = "";
286
+ h += rect(0, 0, W, 44, T.wallFace, "");
287
+ h += rect(0, 0, W, 6, T.wallTrim, "");
288
+ h += rect(0, 37, W, 7, T.wallBase, out(T.ink));
289
+ h += rect(0, 37, W, 2, "rgba(0,0,0,.22)", "");
290
+ return grp(0, 0, W, 44, 3, h);
291
+ }
292
+
293
+ /* ---- character roster (warm/daylight palette, keeps identity) ---- */
294
+ const CAST = {
295
+ stacey: { skin: "#f2c9a8", hair: "#8a5230", shirt: "#2f9a93", style: "long" },
296
+ kevin: { skin: "#ecc7a2", hair: "#34323f", shirt: "#c79a3a", style: "neat", glasses: true, tie: "#9c7726" },
297
+ janet: { skin: "#e9b88f", hair: "#241a30", shirt: "#8a5bb0", style: "long", streak: "#d24bb0" },
298
+ brad: { skin: "#f0c3a0", hair: "#6e4422", shirt: "#c8623e", style: "messy", sweat: true },
299
+ you: { skin: "#f0c8a4", hair: "#cfcad8", shirt: "#e7e5ef", style: "short" },
300
+ derek: { skin: "#e7bfa0", hair: "#6f6a52", shirt: "#7fa8c8", style: "bald" },
301
+ };
302
+ const TONE = { stacey: "#34c4b6", kevin: "#ffd56b", janet: "#c98cff", brad: "#ff7a52", you: "#f4f1ff", derek: "#9ec4e6" };
303
+
304
+ /* ---- base carpet div (sits below top wall) ---- */
305
+ function carpet(W, H, T, topY) {
306
+ return `<div style="position:absolute;left:0;top:${topY}px;width:${W}px;height:${H - topY}px;background:${T.floor};
307
+ background-image:
308
+ linear-gradient(0deg, ${T.seam} 2px, transparent 2px),
309
+ linear-gradient(90deg, ${T.seam} 2px, transparent 2px),
310
+ linear-gradient(180deg, ${T.fhi} 1px, transparent 1px),
311
+ linear-gradient(270deg, ${T.fhi} 1px, transparent 1px);
312
+ background-size:${T.tile}px ${T.tile}px;z-index:0;"></div>`;
313
+ }
314
+ function lighting(T) {
315
+ let h = `<div style="position:absolute;inset:0;z-index:9990;pointer-events:none;${T.light}"></div>`;
316
+ if (T.grain) {
317
+ h += `<div style="position:absolute;inset:0;z-index:9991;pointer-events:none;opacity:.5;mix-blend-mode:multiply;
318
+ background-image:radial-gradient(${T.ink} 0.5px, transparent 0.6px);background-size:3px 3px;"></div>`;
319
+ }
320
+ return h;
321
+ }
322
+
323
+ /* =========================================================
324
+ VIGNETTE render (used by the exploration page)
325
+ ========================================================= */
326
+ function render(root, T) {
327
+ root.classList.add("o34-root");
328
+ const W = 560, H = 384, wallH = 56;
329
+ root.style.cssText += `position:relative;width:${W}px;height:${H}px;overflow:hidden;background:${T.floor};`;
330
+ let h = carpet(W, H, T, wallH);
331
+ h += rect(0, 0, W, wallH, T.wallFace, "z-index:1;");
332
+ h += rect(0, 0, W, 6, T.wallTrim, "z-index:1;");
333
+ h += rect(0, wallH - 7, W, 7, T.wallBase, out(T.ink) + "z-index:1;");
334
+ h += rect(0, wallH - 7, W, 2, "rgba(0,0,0,.22)", "z-index:1;");
335
+ [60, 360].forEach((wx) => {
336
+ h += rect(wx, 12, 70, 30, "#aee0f2", out(T.ink) + "z-index:2;");
337
+ h += rect(wx, 12, 70, 8, "#cdeefb", "z-index:2;");
338
+ h += rect(wx + 33, 12, 4, 30, T.wallBase, "z-index:2;");
339
+ h += rect(wx, 26, 70, 3, T.wallBase, "z-index:2;");
340
+ });
341
+ h += rect(150, 9, 56, 18, "#eceae2", out(T.ink) + "z-index:2;border-radius:2px;");
342
+ for (let i = 0; i < 5; i++) h += rect(156 + i * 10, 20, 6, 3, "#c7c4ba", "z-index:2;");
343
+ h += grp(248, 10, 64, 34, 2, whiteboard(0, 0, T));
344
+ h += rect(0, wallH, 7, H - wallH, T.wallBase, out(T.ink) + "z-index:1;");
345
+ h += rect(W - 7, wallH, 7, H - wallH, T.wallBase, out(T.ink) + "z-index:1;");
346
+ h += plant(18, 64, T, true);
347
+ h += cooler(20, 250, T);
348
+ h += printer(508, 112, T);
349
+ h += plant(512, 320, T);
350
+ if (T.density !== "low") h += papers(150, 300, T, 5);
351
+ const colX = [96, 252, 408], rowA = 92, rowB = 224;
352
+ h += station({ x: colX[0], y: rowA, char: CAST.stacey, name: "STACEY", tone: TONE.stacey }, T);
353
+ h += station({ x: colX[1], y: rowA, char: CAST.kevin, name: "KEVIN", tone: TONE.kevin, screen: "chart" }, T);
354
+ h += station({ x: colX[2], y: rowA, char: CAST.janet, name: "JANET", tone: TONE.janet }, T);
355
+ h += station({ x: colX[0], y: rowB, char: CAST.brad, name: "BRAD", tone: TONE.brad, papers: true, alert: true, lean: true }, T);
356
+ h += station({ x: colX[1], y: rowB, char: CAST.you, name: "YOU", tone: TONE.you, ring: true }, T);
357
+ h += station({ x: colX[2], y: rowB, char: CAST.derek, name: "DEREK", tone: TONE.derek, screen: "off", still: true }, T);
358
+ h += lighting(T);
359
+ root.innerHTML = h;
360
+ }
361
+
362
+ /* =========================================================
363
+ CANONICAL game floor (640x400) — named cast + rooms
364
+ ========================================================= */
365
+ function renderCanonical(root, T) {
366
+ root.classList.add("o34-root");
367
+ const W = 640, H = 400, wallH = 44;
368
+ root.style.cssText += `position:relative;width:${W}px;height:${H}px;overflow:hidden;background:${T.floor};`;
369
+ let h = carpet(W, H, T, wallH);
370
+
371
+ // top wall + fixtures
372
+ h += wallTop(W, T);
373
+ [22, 470].forEach((wx) => {
374
+ h += grp(wx, 11, 80, 26, 4, rect(0, 0, 80, 26, "#aee0f2", out(T.ink)) + rect(0, 0, 80, 7, "#cdeefb", "") + rect(38, 0, 4, 26, T.wallBase, "") + rect(0, 12, 80, 3, T.wallBase, ""));
375
+ });
376
+ h += grp(250, 8, 60, 18, 4, rect(0, 0, 60, 18, "#eceae2", out(T.ink) + "border-radius:2px;") + [0,1,2,3,4].map(i => rect(6 + i * 10, 11, 6, 3, "#c7c4ba", "")).join(""));
377
+
378
+ // ROOMS
379
+ h += room(8, wallH, 210, 104, "BOARDROOM", T, { wood: true });
380
+ h += table(48, wallH + 30, 120, 40, T);
381
+ h += door(98, wallH + 96, T);
382
+
383
+ h += room(424, wallH, 208, 104, "BREAK ROOM", T, { wood: true });
384
+ h += couch(452, wallH + 30, T);
385
+ h += fridge(592, wallH + 14, T);
386
+ h += cooler(556, wallH + 56, T);
387
+ h += grp(524, wallH + 60, 30, 18, 320, rect(0, 6, 30, 12, "#6b4a2e", out(T.ink) + "border-radius:50%;") + rect(8, 0, 14, 12, "#fbf8ee", out(T.ink))); // small round table + cup
388
+
389
+ // corridor centre: whiteboard on wall + a plant
390
+ h += grp(290, 9, 64, 30, 4, whiteboard(0, 0, T));
391
+ h += plant(300, wallH + 14, T, false);
392
+
393
+ // BULLPEN — the named cast
394
+ const colX = [38, 290, 542 - 30];
395
+ const rowA = wallH + 116, rowB = wallH + 240;
396
+ h += station({ x: colX[0], y: rowA, char: CAST.stacey, name: "STACEY", tone: TONE.stacey }, T);
397
+ h += station({ x: colX[1], y: rowA, char: CAST.kevin, name: "KEVIN", tone: TONE.kevin, screen: "chart" }, T);
398
+ h += station({ x: colX[2], y: rowA, char: CAST.janet, name: "JANET", tone: TONE.janet }, T);
399
+ h += station({ x: colX[0], y: rowB, char: CAST.brad, name: "BRAD", tone: TONE.brad, papers: true, alert: true, lean: true }, T);
400
+ h += station({ x: colX[1], y: rowB, char: CAST.you, name: "YOU", tone: TONE.you, ring: true }, T);
401
+ h += station({ x: colX[2], y: rowB, char: CAST.derek, name: "DEREK", tone: TONE.derek, screen: "off", still: true }, T);
402
+
403
+ // extra props
404
+ h += printer(594, wallH + 150, T);
405
+ h += papers(150, wallH + 224, T, 5);
406
+
407
+ h += lighting(T);
408
+ root.innerHTML = h;
409
+ }
410
+
411
+ /* =========================================================
412
+ THEMES
413
+ ========================================================= */
414
+ const THEMES = {
415
+ warmlight: {
416
+ name: "WARMLIGHT", tile: 28,
417
+ floor: "#8d8b82", seam: "#76746b", fhi: "rgba(255,255,255,.06)",
418
+ wallFace: "#d8d4c8", wallBase: "#b4af9f", wallTrim: "#ece8dc",
419
+ deskTop: "#c3a169", deskFront: "#997b46",
420
+ panelTop: "#c6c2b6", chair: "#3b3e46", chair2: "#4a4e58", screen: "#1c3a5a",
421
+ pot: "#b5703a", leaf: "#5f8a3f", leaf2: "#74a352",
422
+ wood: "#b58a55", woodSeam: "#9a763f", roomFloor: "#878579",
423
+ ink: "#2e2a22", density: "med",
424
+ light: "background:radial-gradient(ellipse at 50% 12%, rgba(255,248,226,.16) 0%, transparent 55%), radial-gradient(ellipse at 50% 122%, rgba(20,16,30,.30) 0%, transparent 60%);",
425
+ },
426
+ coolopen: {
427
+ name: "COOL OPEN-PLAN", tile: 32,
428
+ floor: "#9a9aa0", seam: "#7f7f86", fhi: "rgba(255,255,255,.08)",
429
+ wallFace: "#e3e4e7", wallBase: "#c2c3c8", wallTrim: "#f2f3f5",
430
+ deskTop: "#d8d2c4", deskFront: "#b1ab9b",
431
+ panelTop: "#cfd0d4", chair: "#2f3138", chair2: "#3c3f48", screen: "#15324d",
432
+ pot: "#9a9da6", leaf: "#5b8f6a", leaf2: "#74ab84",
433
+ wood: "#c2b6a4", woodSeam: "#a59a86", roomFloor: "#93939a",
434
+ ink: "#2f2f38", density: "low",
435
+ light: "background:radial-gradient(ellipse at 50% 8%, rgba(235,245,255,.22) 0%, transparent 60%), radial-gradient(ellipse at 50% 125%, rgba(30,34,46,.20) 0%, transparent 60%);",
436
+ },
437
+ cozyden: {
438
+ name: "COZY DEN", tile: 26,
439
+ floor: "#8a7f6e", seam: "#6f6557", fhi: "rgba(255,224,170,.07)",
440
+ wallFace: "#cdbfa6", wallBase: "#a99b80", wallTrim: "#e6dcc4",
441
+ deskTop: "#b98f54", deskFront: "#89642f",
442
+ panelTop: "#bda678", chair: "#39322a", chair2: "#4a4136", screen: "#1c3450",
443
+ pot: "#a85f30", leaf: "#5a7d35", leaf2: "#6f9446",
444
+ wood: "#a87c45", woodSeam: "#8a6230", roomFloor: "#827868",
445
+ ink: "#2c2114", density: "high", grain: true,
446
+ light: "background:radial-gradient(ellipse at 50% 14%, rgba(255,214,140,.20) 0%, transparent 55%), radial-gradient(ellipse at 50% 120%, rgba(30,18,8,.40) 0%, transparent 62%);",
447
+ },
448
+ };
449
+
450
+ window.Office34 = {
451
+ render, renderCanonical, THEMES, CAST, TONE,
452
+ person, station, plant, printer, cooler, papers, table, couch, fridge, door, whiteboard, room, wallTop,
453
+ };
454
+ })();
static/js/papertrail.js ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // paper trail side panel — dollars, never HP (DESIGN_SYSTEM decisions log)
2
+ import { G } from "./state.js";
3
+ import { charColor } from "./sprites.js";
4
+
5
+ const fmtK = (n) => {
6
+ const a = Math.abs(n);
7
+ return (n >= 0 ? "+$" : "-$") + (a >= 1000 ? `${Math.round(a / 1000)}K` : a);
8
+ };
9
+
10
+ let rendered = 0;
11
+
12
+ export function renderTrail(entries) {
13
+ const box = G.els["pt-entries"];
14
+ if (!entries || entries.length === 0) return;
15
+ const empty = box.querySelector(".pt-empty");
16
+ if (empty) empty.remove();
17
+ // append only the new ones so the slide-in animation plays once each
18
+ for (let i = rendered; i < entries.length; i++) {
19
+ const e = entries[i];
20
+ const el = document.createElement("div");
21
+ el.className = "pt-entry";
22
+ const color = e.npc === "board" ? "#ffc73b"
23
+ : e.npc === "player" ? "var(--bds-white)" : charColor(e.npc);
24
+ el.innerHTML =
25
+ `<span class="pt-npc" style="color:${color}">${(e.npc || "?").toUpperCase()}</span> ` +
26
+ `<span class="pt-delta ${e.delta >= 0 ? "up" : "down"}">${e.delta !== 0 ? fmtK(e.delta) : ""}</span>` +
27
+ `<div class="pt-text">${escapeHtml(e.text)}</div>`;
28
+ box.appendChild(el);
29
+ }
30
+ rendered = entries.length;
31
+ box.scrollTop = box.scrollHeight;
32
+ }
33
+
34
+ export function resetTrail() {
35
+ rendered = 0;
36
+ G.els["pt-entries"].innerHTML =
37
+ '<div class="pt-empty">&gt; nothing is on fire. yet.</div>';
38
+ }
39
+
40
+ export function escapeHtml(s) {
41
+ const d = document.createElement("div");
42
+ d.textContent = s == null ? "" : String(s);
43
+ return d.innerHTML;
44
+ }
static/js/particles.js ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // DOM particle effects inside #fx (UI_UX.md §8)
2
+ import { G } from "./state.js";
3
+
4
+ export function floatText(x, y, text, color, down = false) {
5
+ const el = document.createElement("div");
6
+ el.className = "float-text" + (down ? " down" : "");
7
+ el.style.left = `${x}px`; el.style.top = `${y}px`; el.style.color = color;
8
+ el.textContent = text;
9
+ G.els.fx.appendChild(el);
10
+ setTimeout(() => el.remove(), 1700);
11
+ }
12
+
13
+ function pixel(x, y, color, size = 4) {
14
+ const el = document.createElement("div");
15
+ el.className = "pixel-part";
16
+ el.style.cssText += `;left:${x}px;top:${y}px;width:${size}px;height:${size}px;
17
+ background:${color};`;
18
+ G.els.fx.appendChild(el);
19
+ return el;
20
+ }
21
+
22
+ export function heartFloat(x, y) {
23
+ const el = document.createElement("div");
24
+ el.className = "pixel-part";
25
+ el.appendChild(window.BDSChibi.pixelGlyph(
26
+ window.BDSChibi.GLYPHS.heart, 3, "var(--bds-neon-magenta)", true));
27
+ el.style.left = `${x}px`; el.style.top = `${y}px`;
28
+ el.animate([{ transform: "translateY(0)", opacity: 1 },
29
+ { transform: "translateY(-34px)", opacity: 0 }],
30
+ { duration: 1100, easing: "steps(6)" });
31
+ G.els.fx.appendChild(el);
32
+ setTimeout(() => el.remove(), 1100);
33
+ }
34
+
35
+ export function confettiBurst() {
36
+ const colors = ["var(--bds-brad)", "var(--bds-stacey)", "var(--bds-kevin)",
37
+ "var(--bds-janet)", "var(--bds-derek)"];
38
+ for (let i = 0; i < 36; i++) {
39
+ const el = pixel(320, 200, colors[i % 5]);
40
+ const dx = (Math.random() - 0.5) * 420;
41
+ const dy = 120 + Math.random() * 260;
42
+ el.animate(
43
+ [{ transform: "translate(0,0)" },
44
+ { transform: `translate(${dx}px,${dy}px)`, opacity: 0.2 }],
45
+ { duration: 1400 + Math.random() * 600, easing: "steps(10)" });
46
+ setTimeout(() => el.remove(), 2000);
47
+ }
48
+ }
49
+
50
+ export function revenueRain() {
51
+ for (let i = 0; i < 14; i++) {
52
+ setTimeout(() => {
53
+ const x = 30 + Math.random() * 580;
54
+ const el = document.createElement("div");
55
+ el.className = "pixel-part";
56
+ el.textContent = "$";
57
+ el.style.cssText += `;left:${x}px;top:-12px;color:var(--bds-neon-lime);
58
+ font-size:10px;text-shadow:0 0 6px var(--bds-neon-lime);`;
59
+ el.animate([{ transform: "translateY(0)" },
60
+ { transform: "translateY(500px)" }],
61
+ { duration: 1500, easing: "steps(12)" });
62
+ G.els.fx.appendChild(el);
63
+ setTimeout(() => el.remove(), 1500);
64
+ }, i * 120);
65
+ }
66
+ }
67
+
68
+ export function moraleWave(npcObjs) {
69
+ const ordered = Object.values(npcObjs).sort((a, b) => a.x - b.x);
70
+ ordered.forEach((npc, i) => {
71
+ setTimeout(() => {
72
+ if (!npc.sprite) return;
73
+ npc.sprite.animate(
74
+ [{ transform: "translateY(0)" }, { transform: "translateY(5px)" },
75
+ { transform: "translateY(0)" }],
76
+ { duration: 360, easing: "steps(4)" });
77
+ }, i * 130);
78
+ });
79
+ }
80
+
81
+ export function zzz(x, y) {
82
+ const el = document.createElement("div");
83
+ el.className = "pixel-part";
84
+ el.appendChild(window.BDSChibi.pixelGlyph(
85
+ window.BDSChibi.GLYPHS.z, 2, "var(--bds-derek)", false));
86
+ el.style.left = `${x}px`; el.style.top = `${y}px`;
87
+ el.animate([{ transform: "translate(0,0)", opacity: 1 },
88
+ { transform: "translate(10px,-18px)", opacity: 0 }],
89
+ { duration: 2200, easing: "steps(6)" });
90
+ G.els.fx.appendChild(el);
91
+ setTimeout(() => el.remove(), 2200);
92
+ }
93
+
94
+ export function steam(x, y) {
95
+ const el = pixel(x + (Math.random() * 6 - 3), y, "rgba(244,241,255,.5)", 3);
96
+ el.animate([{ transform: "translateY(0)", opacity: 0.6 },
97
+ { transform: "translateY(-16px)", opacity: 0 }],
98
+ { duration: 1400, easing: "steps(5)" });
99
+ setTimeout(() => el.remove(), 1400);
100
+ }
static/js/player.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // player movement: WASD/arrows + click-to-move, AABB collision
2
+ import { collides, SPOTS } from "./map.js";
3
+ import { makeChar } from "./sprites.js";
4
+
5
+ const SPEED = 130; // px/s
6
+
7
+ export function createPlayer(worldEl) {
8
+ const obj = makeChar(worldEl, "player", SPOTS.playerStart.x,
9
+ SPOTS.playerStart.y, { tag: false });
10
+ obj.target = null; // click-to-move destination
11
+ obj.frozen = false;
12
+
13
+ obj.update = function (dt, keys) {
14
+ if (this.frozen) { this.setPose("idle", this.facing); return; }
15
+ let dx = 0, dy = 0;
16
+ if (keys.up) dy -= 1; if (keys.down) dy += 1;
17
+ if (keys.left) dx -= 1; if (keys.right) dx += 1;
18
+
19
+ if (dx === 0 && dy === 0 && this.target) {
20
+ const tx = this.target.x - this.x, ty = this.target.y - this.y;
21
+ const dist = Math.hypot(tx, ty);
22
+ if (dist < 6) this.target = null;
23
+ else { dx = tx / dist; dy = ty / dist; }
24
+ } else if (dx !== 0 || dy !== 0) {
25
+ this.target = null; // keys override click target
26
+ }
27
+
28
+ if (dx === 0 && dy === 0) { this.setPose("idle", this.facing); return; }
29
+
30
+ const len = Math.hypot(dx, dy);
31
+ const nx = this.x + (dx / len) * SPEED * dt;
32
+ const ny = this.y + (dy / len) * SPEED * dt;
33
+ // axis-separated collision so the player slides along walls
34
+ if (!collides(nx, this.y)) this.setPos(nx, this.y);
35
+ if (!collides(this.x, ny)) this.setPos(this.x, ny);
36
+
37
+ const facing = Math.abs(dx) > Math.abs(dy)
38
+ ? (dx > 0 ? "right" : "left") : (dy > 0 ? "down" : "up");
39
+ this.setPose("walk", facing);
40
+ };
41
+
42
+ obj.distTo = function (spot) {
43
+ return Math.hypot(this.x - spot.x, this.y - spot.y);
44
+ };
45
+
46
+ return obj;
47
+ }
static/js/sprites.js ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // thin wrapper over the design system's BDSChibi top-down sprites
2
+ const HAIR = {
3
+ player: "#5d5b86", brad: "#7a4626", stacey: "#9a5a32",
4
+ kevin: "#33324a", janet: "#241a30", derek: "#6f6a52",
5
+ };
6
+ const COLOR = {
7
+ player: "var(--bds-white)", brad: "var(--bds-brad)",
8
+ stacey: "var(--bds-stacey)", kevin: "var(--bds-kevin)",
9
+ janet: "var(--bds-janet)", derek: "var(--bds-derek)",
10
+ };
11
+
12
+ export function charColor(id) { return COLOR[id] || "var(--bds-grey)"; }
13
+
14
+ // a positioned character on the office floor: sprite + name tag + optional desk
15
+ export function makeChar(parent, id, x, y, opts = {}) {
16
+ const root = document.createElement("div");
17
+ root.style.cssText =
18
+ `position:absolute;left:${x}px;top:${y}px;width:0;height:0;z-index:5;`;
19
+ parent.appendChild(root);
20
+
21
+ // contact shadow under the feet — grounds the sprite on the floor
22
+ const feet = document.createElement("div");
23
+ feet.style.cssText = `position:absolute;left:-9px;top:0px;width:18px;
24
+ height:5px;background:rgba(58,61,73,.30);border-radius:50%;z-index:1;`;
25
+ root.appendChild(feet);
26
+
27
+ const obj = {
28
+ id, x, y, root, sprite: null, pose: null, facing: "down", dim: false,
29
+ setPose(pose, facing = this.facing) {
30
+ if (pose === this.pose && facing === this.facing && this.sprite) return;
31
+ this.pose = pose; this.facing = facing;
32
+ if (this.sprite) this.sprite.remove();
33
+ this.sprite = window.BDSChibi.topSprite({
34
+ who: id, pose, still: pose === "still", facing, dim: this.dim,
35
+ });
36
+ this.sprite.style.left = "-14px";
37
+ this.sprite.style.top = "-30px";
38
+ root.insertBefore(this.sprite, root.firstChild);
39
+ },
40
+ setPos(nx, ny) {
41
+ this.x = nx; this.y = ny;
42
+ root.style.left = `${nx}px`; root.style.top = `${ny}px`;
43
+ },
44
+ setDim(v) { if (v !== this.dim) { this.dim = v; const p = this.pose;
45
+ this.pose = null; this.setPose(p); } },
46
+ remove() { root.remove(); },
47
+ };
48
+
49
+ if (opts.desk) root.appendChild(window.BDSChibi.desk({ dark: opts.darkDesk }));
50
+ if (opts.tag !== false)
51
+ root.appendChild(window.BDSChibi.tag(id.toUpperCase(), COLOR[id], { top: 6 }));
52
+ obj.setPose(opts.pose || "idle", opts.facing || "down");
53
+ return obj;
54
+ }
55
+
56
+ // ---- full designed chibi characters (Character Sprite Sheet v1.0) ----
57
+
58
+ // per-NPC default expression when they pitch a crisis
59
+ const CRISIS_EXPRESSION = {
60
+ brad: { armPose: "out", mouth: "worried", sweat: true },
61
+ stacey: { armPose: "wring", mouth: "worried" },
62
+ kevin: { armPose: "point", mouth: "smirk" },
63
+ janet: { armPose: "open", mouth: "open" },
64
+ derek: { mouth: "flat" },
65
+ player: { mouth: "neutral" },
66
+ };
67
+
68
+ // outcome animation trigger → chibi expression
69
+ const OUTCOME_EXPRESSION = {
70
+ npc_happy: { eyes: "happy", mouth: "smile", armPose: "open" },
71
+ npc_celebrating: { eyes: "happy", mouth: "open", armPose: "open", anim: "a-bounce" },
72
+ npc_angry: { mouth: "frown", armPose: "hips", anim: "a-shiver" },
73
+ npc_confused: { mouth: "wavy", tilt: 8 },
74
+ npc_devastated: { eyes: "closed", mouth: "frown", tilt: 5 },
75
+ npc_crying: { tears: true, mouth: "frown" },
76
+ npc_smug: { mouth: "smirk", armPose: "behindhead" },
77
+ npc_suspicious: { mouth: "flat", armPose: "crossed", tilt: -5 },
78
+ npc_hiding: { mouth: "worried", sweat: true },
79
+ npc_grateful: { eyes: "happy", mouth: "smile", blush: true },
80
+ heart_float: { eyes: "heart", mouth: "smile", blush: true },
81
+ disaster_flash: { eyes: "wide", mouth: "wavy", sweat: true },
82
+ revenue_rain: { eyes: "happy", mouth: "open", armPose: "open" },
83
+ };
84
+
85
+ /* a full chibi in a fixed-size box, scaled to fit (the chibi element itself
86
+ is ~130-150px tall; transform scale keeps the layout box stable) */
87
+ export function chibiInBox(id, opts = {}, boxW = 72, boxH = 66) {
88
+ const box = document.createElement("div");
89
+ box.style.cssText = `position:relative;width:${boxW}px;height:${boxH}px;
90
+ flex:none;overflow:visible;`;
91
+ const el = window.BDSChibi.chibi(id, opts.state || "idle", opts);
92
+ // scale lives on a wrapper: the chibi's own idle/talk animations write to
93
+ // its transform and would clobber an inline scale set on the element
94
+ const realH = parseInt(el.style.height, 10) || 150;
95
+ const scale = opts.fit || boxH / realH;
96
+ const wrap = document.createElement("div");
97
+ wrap.style.cssText = `position:absolute;left:50%;bottom:0;
98
+ transform-origin:bottom center;
99
+ transform:translateX(-50%) scale(${scale})` +
100
+ (opts.tilt ? ` rotate(${opts.tilt}deg)` : "") + ";";
101
+ wrap.appendChild(el);
102
+ box.appendChild(wrap);
103
+ return box;
104
+ }
105
+
106
+ export function crisisPortrait(id) {
107
+ return chibiInBox(id, { ...CRISIS_EXPRESSION[id] });
108
+ }
109
+
110
+ export function outcomePortrait(id, animation) {
111
+ const expr = OUTCOME_EXPRESSION[animation] || CRISIS_EXPRESSION[id] || {};
112
+ return chibiInBox(id, { ...expr });
113
+ }
static/js/state.js ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // shared client state — the server snapshot plus client-only bookkeeping
2
+ export const G = {
3
+ sessionId: null,
4
+ state: null, // latest server snapshot (client-safe, no hidden stats)
5
+ phase: "title", // mirrors server phase + client-side nuance
6
+ busy: false, // a network call is in flight
7
+ pendingEvent: null, // fetched event waiting for the player to walk over
8
+ roamTimer: 0, // seconds until next event fires
9
+ telegraphed: false,
10
+ dialogueOpen: false,
11
+ els: {},
12
+ };
13
+
14
+ export function cacheEls() {
15
+ for (const id of ["stage", "floor", "world", "fx", "boardroom", "dim",
16
+ "flash", "edge-pulse", "wipe", "stamp", "hud", "dialogue",
17
+ "gift-panel", "title-screen", "review-screen", "pt-entries",
18
+ "rev-number", "rev-fill", "boss-title", "crisis-counter",
19
+ "pocket", "hr-badge", "hud-banner", "stage-wrap", "comic"]) {
20
+ G.els[id] = document.getElementById(id);
21
+ }
22
+ }
23
+
24
+ export function setState(snapshot) {
25
+ G.state = snapshot;
26
+ G.phase = snapshot.phase;
27
+ }
static/js/touch.js ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // touch.js — on-screen controls for phones/tablets (no physical keyboard).
2
+ //
3
+ // A left thumb-joystick drives the same `keys` object the player already reads,
4
+ // so movement reuses player.update untouched. Right-side ACTION and GIFT
5
+ // buttons fire the same handlers as SPACE and G. Created only on touch devices;
6
+ // CSS hides them whenever a menu/dialogue/comic owns the screen. Tap-to-move
7
+ // (bindStageClick) keeps working too.
8
+ import { keys, emit } from "./input.js";
9
+
10
+ const DEAD = 0.3; // joystick deadzone as a fraction of its radius
11
+
12
+ export function isTouch() {
13
+ return "ontouchstart" in window || navigator.maxTouchPoints > 0;
14
+ }
15
+
16
+ export function setupTouch() {
17
+ if (!isTouch()) return;
18
+ document.body.classList.add("touch");
19
+ buildJoystick();
20
+ buildButtons();
21
+ }
22
+
23
+ function clearKeys() {
24
+ keys.up = keys.down = keys.left = keys.right = false;
25
+ }
26
+
27
+ function buildJoystick() {
28
+ const base = document.createElement("div");
29
+ base.id = "tc-stick";
30
+ base.innerHTML = `<div class="tc-thumb"></div>`;
31
+ document.getElementById("viewport").appendChild(base);
32
+ const thumb = base.querySelector(".tc-thumb");
33
+ let id = null, cx = 0, cy = 0, r = 1;
34
+
35
+ function place(t) {
36
+ let dx = (t.clientX - cx) / r, dy = (t.clientY - cy) / r;
37
+ const mag = Math.hypot(dx, dy);
38
+ if (mag > 1) { dx /= mag; dy /= mag; }
39
+ thumb.style.transform = `translate(${dx * r * 0.55}px, ${dy * r * 0.55}px)`;
40
+ keys.left = dx < -DEAD; keys.right = dx > DEAD;
41
+ keys.up = dy < -DEAD; keys.down = dy > DEAD;
42
+ }
43
+
44
+ base.addEventListener("touchstart", (e) => {
45
+ const rect = base.getBoundingClientRect();
46
+ r = rect.width / 2; cx = rect.left + r; cy = rect.top + r;
47
+ id = e.changedTouches[0].identifier;
48
+ place(e.changedTouches[0]);
49
+ e.preventDefault();
50
+ }, { passive: false });
51
+
52
+ base.addEventListener("touchmove", (e) => {
53
+ for (const t of e.changedTouches)
54
+ if (t.identifier === id) { place(t); e.preventDefault(); }
55
+ }, { passive: false });
56
+
57
+ const end = (e) => {
58
+ for (const t of e.changedTouches)
59
+ if (t.identifier === id) {
60
+ id = null; clearKeys();
61
+ thumb.style.transform = "translate(0,0)";
62
+ }
63
+ };
64
+ base.addEventListener("touchend", end);
65
+ base.addEventListener("touchcancel", end);
66
+ }
67
+
68
+ function buildButtons() {
69
+ const wrap = document.createElement("div");
70
+ wrap.id = "tc-buttons";
71
+ wrap.innerHTML = `
72
+ <button class="tc-btn tc-gift" aria-label="gift">GIFT</button>
73
+ <button class="tc-btn tc-action" aria-label="action">ACT</button>`;
74
+ document.getElementById("viewport").appendChild(wrap);
75
+ const bind = (sel, name, arg) => {
76
+ const b = wrap.querySelector(sel);
77
+ // touchstart for zero-delay response; preventDefault stops the synth click
78
+ b.addEventListener("touchstart", (e) => {
79
+ e.preventDefault(); b.classList.add("down"); emit(name, arg);
80
+ }, { passive: false });
81
+ const up = () => b.classList.remove("down");
82
+ b.addEventListener("touchend", up);
83
+ b.addEventListener("touchcancel", up);
84
+ };
85
+ bind(".tc-action", "interact");
86
+ bind(".tc-gift", "gift");
87
+ }
tests/local_llm_server.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local real-AI inference server — same /generate contract as the Modal app,
2
+ same runtime (llama.cpp) and same Qwen3.5-9B GGUF, just on CPU for development.
3
+
4
+ Usage:
5
+ python tests/local_llm_server.py [--port 8081] [--model models/<file>.gguf]
6
+ Then point the game at it:
7
+ set MODAL_URL=http://127.0.0.1:8081
8
+ set BDS_LLM_TIMEOUT=90
9
+ python app.py
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import time
16
+
17
+ import uvicorn
18
+ from fastapi import FastAPI
19
+
20
+ parser = argparse.ArgumentParser()
21
+ parser.add_argument("--port", type=int, default=8081)
22
+ parser.add_argument("--model", default="models/Qwen_Qwen3.5-9B-Q4_K_M.gguf")
23
+ args = parser.parse_args()
24
+
25
+ print(f"loading {args.model} ...")
26
+ from llama_cpp import Llama, LlamaGrammar # noqa: E402 (slow import)
27
+ from llama_cpp.llama_grammar import json_schema_to_gbnf # noqa: E402
28
+
29
+ llm = Llama(model_path=args.model, n_ctx=4096, n_threads=None, verbose=False)
30
+ print("model loaded.")
31
+
32
+ app = FastAPI()
33
+
34
+
35
+ @app.post("/generate")
36
+ def generate(body: dict):
37
+ t0 = time.time()
38
+ try:
39
+ schema = body["schema"]
40
+ grammar = LlamaGrammar.from_string(
41
+ json_schema_to_gbnf(json.dumps(schema)), verbose=False)
42
+ # mirror the Modal path: the empty <think> block disables Qwen3.5's
43
+ # thinking mode so the JSON grammar takes over immediately
44
+ prompt = (
45
+ f"<|im_start|>system\n{body['system_prompt']}\n"
46
+ f"GAME STATE JSON:\n{json.dumps(body.get('context', {}))}\n<|im_end|>\n"
47
+ f"<|im_start|>user\n{body['user_prompt']}<|im_end|>\n"
48
+ f"<|im_start|>assistant\n<think>\n\n</think>\n\n"
49
+ )
50
+ out = llm(prompt, max_tokens=1024, temperature=0.4, grammar=grammar)
51
+ text = out["choices"][0]["text"]
52
+ data = json.loads(text)
53
+ return {"ok": True, "data": data, "ms": int((time.time() - t0) * 1000)}
54
+ except Exception as exc: # caller falls back; never crash the endpoint
55
+ return {"ok": False, "error": str(exc)[:300],
56
+ "ms": int((time.time() - t0) * 1000)}
57
+
58
+
59
+ @app.get("/healthz")
60
+ def healthz():
61
+ return {"ok": True}
62
+
63
+
64
+ if __name__ == "__main__":
65
+ uvicorn.run(app, host="127.0.0.1", port=args.port)