Commit ·
78c0f6e
0
Parent(s):
Brad Did Something - Gradio/FastAPI Space on HF
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +20 -0
- .gitattributes +6 -0
- .gitignore +22 -0
- Dockerfile +23 -0
- README.md +118 -0
- app.py +228 -0
- game/__init__.py +0 -0
- game/comic.py +73 -0
- game/context.py +52 -0
- game/economy.py +129 -0
- game/events.py +381 -0
- game/fallbacks.py +313 -0
- game/idle.py +161 -0
- game/llm.py +433 -0
- game/presentation.py +257 -0
- game/prompts.py +408 -0
- game/relationships.py +114 -0
- game/schemas.py +211 -0
- game/state.py +154 -0
- game/trace.py +36 -0
- game/validator.py +450 -0
- modal_app/image.py +123 -0
- modal_app/inference.py +120 -0
- requirements.txt +5 -0
- run_modal.ps1 +18 -0
- static/audio/bgm.mp3 +3 -0
- static/css/game.css +516 -0
- static/css/tokens.css +337 -0
- static/fonts/PressStart2P-Regular.woff2 +3 -0
- static/index.html +77 -0
- static/js/api.js +33 -0
- static/js/audio.js +277 -0
- static/js/boardroom.js +159 -0
- static/js/chibi.js +526 -0
- static/js/comic.js +60 -0
- static/js/dialogue.js +355 -0
- static/js/effects.js +75 -0
- static/js/hud.js +75 -0
- static/js/input.js +46 -0
- static/js/main.js +684 -0
- static/js/map.js +662 -0
- static/js/npcs.js +87 -0
- static/js/office34.js +454 -0
- static/js/papertrail.js +44 -0
- static/js/particles.js +100 -0
- static/js/player.js +47 -0
- static/js/sprites.js +113 -0
- static/js/state.js +27 -0
- static/js/touch.js +87 -0
- tests/local_llm_server.py +65 -0
.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">> 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 ▶</button>
|
| 66 |
+
<div class="ts-keys">> WASD move · SPACE talk · G gift · 1/2 choose · 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);">> 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 |
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 ▶</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 ▶</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}">> 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 = `> ${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 |
+
> ${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 |
+
> salary advance arrived. +$1,000 pocket money [payday]</div>` : ""}
|
| 196 |
+
${outcome.relationship_unlocked ? `<div class="dlg-after-you" style="color:#ffc73b">
|
| 197 |
+
> something changed with ${outcome.npc_id}. [relationship deepened]</div>` : ""}
|
| 198 |
+
<button class="btn-next">NEXT ▶</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 ▶</button>
|
| 219 |
+
</div>
|
| 220 |
+
<button class="btn-quick" data-walkaway style="margin-top:8px;">WALK AWAY ▶</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 |
+
> that ${relDelta > 0 ? "landed well" : "did not land"}</div>` : ""}
|
| 243 |
+
<button class="btn-next">BACK TO WORK ▶</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 ▶</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 ? "●" : "○").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);">♥</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">> 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 —
|
| 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 |
+
> board assessment: ${outcome.score}/100<br>
|
| 341 |
+
> revenue impact: <span style="color:${delta >= 0
|
| 342 |
+
? "var(--bds-neon-lime)" : "var(--bds-brad)"}">${deltaTxt}</span><br>
|
| 343 |
+
${outcome.budget_unlock ? "> discretionary budget unlocked: +$" +
|
| 344 |
+
outcome.budget_unlock.toLocaleString() + "<br>" : ""}
|
| 345 |
+
${outcome.board_scrutiny_public
|
| 346 |
+
? "> board scrutiny: HIGH [!]<br>" : ""}
|
| 347 |
+
> new title: ${escapeHtml(outcome.boss_title).toUpperCase()}
|
| 348 |
+
</div>
|
| 349 |
+
<button class="btn-next">${outcome.final
|
| 350 |
+
? "FACE THE QUARTERLY REVIEW ▶" : "RETURN TO THE FLOOR ▶"}</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 ▶</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">> 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)
|