diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..02ae7c5a08df60f6309bd2f172cecdace9ef2150 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.git/ +models/ +*.gguf +*.log +trace.log +.bds_modal_token +.venv/ +venv/ +node_modules/ + +# heavy, non-runtime assets — the running app never reads these +lora/ +Design System/ +# design/engineering docs (also gitignored) — not needed in the image +*.md +!README.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..98d01e8339b3b8d5be2896f285e06d1be04e5204 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +*.woff2 filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.ico filter=lfs diff=lfs merge=lfs -text +*.mp3 filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..47d6c2722a27bfce9541c31aa15e4dc17c2ec1df --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# secrets — never commit or upload +.bds_modal_token + +# local model weights +models/ + +# test artifacts + runtime logs +tests/shots/ +logs/ +__pycache__/ +*.pyc +.pytest_cache/ + +# design + engineering docs — keep them local, don't ship to the Space. +# (README.md MUST stay — HF reads the Space frontmatter from it.) +/*.md +!/README.md + +# non-runtime asset folders (lots of binaries) — not needed by the running app +lora/ +tests/comic_samples/ +Design System/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..ad4a0b0cd7218b6b5eb6aa437fc8776a87cb72de --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# HF Space runs this as `sdk: docker` (see README frontmatter). We can't use +# `sdk: gradio` because that runner imports a module-level `demo` and never +# starts uvicorn — our app is a FastAPI server with Gradio *mounted* on top, so +# it must be launched explicitly. Gradio stays mounted at "/", so the Space +# still "uses Gradio". Local dev is unchanged: `python app.py`. +FROM python:3.11-slim + +# HF Spaces execute as a non-root user (uid 1000); give it a writable home. +RUN useradd -m -u 1000 user +WORKDIR /home/user/app + +COPY --chown=user requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY --chown=user . . + +USER user +ENV PORT=7860 +EXPOSE 7860 + +# MODAL_URL/MODAL_TOKEN (text) and FLUX_URL/FLUX_TOKEN (comic art) are provided +# as Space secrets; unset → mock text + no comic overlay (still fully playable). +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c78abb78a31b9da5c1523fe5bea1233bf286b278 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +--- +title: Brad Did Something +emoji: 🚀 +colorFrom: pink +colorTo: yellow +sdk: docker +app_port: 7860 +pinned: false +license: mit +short_description: Argue your way to $1M before the quarter ends +--- + +# Brad Did Something + +2D top-down office comedy game. You are the Head of Sales and Partnerships at +Veloura Technologies. Five unhinged underlings, 15 events, one quarter, one +million dollars. The Office meets Silicon Valley, in cozy daylight pixel art. + +All NPC dialogue and outcomes are generated by llama.cpp (Qwen3.5-9B) +running on Modal, with JSON-schema-enforced output validated in Python before +anything reaches the screen. + +## Run locally + +``` +pip install -r requirements.txt +python app.py # → http://localhost:7860 +``` + +With no `MODAL_URL` set, the game runs in **mock mode** — fully playable, with +a template-based offline generator standing in for the model. + +## Wire up live inference (Modal) + +Already deployed to the `qfelix0112` workspace — endpoint: +`https://qfelix0112--brad-did-something-llama-generate.modal.run` +(L4 GPU, warm calls 2-4s, stays warm 5 min between calls). The auth token +lives in `.bds_modal_token` (gitignored — never commit/upload it). + +Play against it: `.\run_modal.ps1` + +To redeploy after changing `modal_app/inference.py`: + +``` +set PYTHONUTF8=1 # Windows: avoids a CLI encoding crash +modal deploy modal_app/inference.py # prints the web endpoint URL +python tests/smoke_modal.py # one real call per call type +``` + +`MODAL_URL` is the full generate-endpoint URL (Modal gives each function its +own URL). For the HF Space, set `MODAL_URL` + `MODAL_TOKEN` as Space secrets. + +## Comic panels (FLUX image generation) + +When a crisis fires, the text model also writes a wordless `image_prompt` (a +single-panel scene) plus a short `comic_caption`. A second Modal GPU app renders +the panel with **FLUX.2 [klein] 4B**; the UI shows it centered over the office +with the caption drawn as crisp text *above* the picture (the image stays +wordless because FLUX garbles in-image text). The art style is prepended +server-side in `/api/comic`, so the model spends its whole budget describing the +scene. Setup renders during the walk to the NPC, payoff after the outcome. It's +purely decorative: if FLUX is unset, slow, or fails, the game shows **no +overlay** and the dialogue opens as usual — the outcome is never blocked. + +``` +set PYTHONUTF8=1 +modal secret create huggingface HF_TOKEN= # FLUX weights are gated +modal deploy modal_app/image.py # prints the endpoint URL +python tests/probe_comic.py # latency + saves a sample +``` + +Then set `FLUX_URL` (the printed `generate_image` URL) and `FLUX_TOKEN` (the +same `bds-auth` `BDS_TOKEN`) — locally or as Space secrets. The model is +env-swappable at deploy: `FLUX_MODEL_ID=black-forest-labs/FLUX.1-schnell` +(Apache-2.0) is a drop-in if FLUX.2's deps/VRAM are troublesome. +**License:** the FLUX.2 line is typically non-commercial — fine for a hackathon +demo; review before any commercial use. + +## Deploy on Hugging Face (Docker) + +The app is built on **`gr.Server`** (Gradio's FastAPI-based server): it serves a +fully custom canvas/DOM frontend from `static/` with zero default Gradio +widgets, while staying a first-class Gradio app. The Space uses `sdk: docker` +(see frontmatter) because HF's `sdk: gradio` runner only launches a module-level +`demo`; the `Dockerfile` runs `uvicorn app:app` on port 7860 (the `gr.Server` +instance is the ASGI app). Set `MODAL_URL`/`MODAL_TOKEN` and (optional) +`FLUX_URL`/`FLUX_TOKEN` as Space secrets. Dry-run locally: + +``` +docker build -t bds . +docker run -p 7860:7860 bds # → http://localhost:7860 +``` + +## Tests + +``` +pytest tests/ # unit tests (validator, economy, events, comic, idle) +python tests/smoke_http.py # full-quarter API playthrough +python tests/smoke_browser.py # headless-browser UI smoke (playwright) +python tests/probe_mobile.py # touch-controls smoke on an emulated phone +python tests/probe_comic.py # live FLUX render + saves sample panels +``` + +## Controls + +WASD / arrows move · SPACE talk / pick up / answer / advance comic · G gift · +1/2 choose options · ENTER send typed response · ESC close · M mute + +**Touch / mobile:** on phones and tablets an on-screen joystick (move) plus +ACT (= SPACE) and GIFT buttons appear automatically; tapping the floor also +walks the player. Portrait layout stacks the HUD and hides the paper-trail +panel. Comics and dialogue are tap-dismissable. + +## Docs + +Design docs (GAME_DESIGN, MECHANICS, EVENTS, …) and engineering docs +(ARCHITECTURE, SCHEMAS, AI_PROMPTS, IMPLEMENTATION_PLAN) live in the repo +root. Start with AGENTS.md. diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..91998f4932e7e7a191cfda267ea56878ac6b386f --- /dev/null +++ b/app.py @@ -0,0 +1,228 @@ +"""Brad Did Something — HF Space entry point. + +Built on `gr.Server` (Gradio's FastAPI-based server, ARCHITECTURE.md D1): it +owns the /api routes and serves a fully custom canvas/DOM frontend from static/ +— no default Gradio widgets. Run: python app.py (HF Docker: uvicorn app:app) +""" +from __future__ import annotations + +import os +import pathlib + +import gradio as gr +import uvicorn +from fastapi import HTTPException +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from game import (comic, economy, events, idle, llm, presentation, prompts, + relationships) +from game.schemas import GIFT_TIERS, NPC_IDS, RESPONSE_TYPES +from game.state import STORE, GameState +from game.trace import trace + +ROOT = pathlib.Path(__file__).parent +# gr.Server is Gradio's own FastAPI-based server (Gradio 5.x+). Using it as the +# base means the whole app is a first-class Gradio app while every route below is +# plain FastAPI — so we serve a fully custom canvas/DOM frontend (static/) with +# no default Gradio widgets. (Replaces the old FastAPI + mount_gradio_app shell.) +api = gr.Server(title="Brad Did Something") +app = api # the ASGI app served by `uvicorn app:app` (local + HF Docker) + + +# ------------------------------------------------------------- request models + +class SessionBody(BaseModel): + session_id: str = Field(min_length=8, max_length=64) + + +class RespondBody(SessionBody): + response_type: str + text: str = Field(default="", max_length=400) + + +class GiftBody(SessionBody): + npc_id: str + tier: str + + +class ChatBody(SessionBody): + npc_id: str + text: str = Field(default="", max_length=400) + + +class ComicBody(SessionBody): + image_prompt: str = Field(default="", max_length=400) + + +def _get_state(session_id: str) -> GameState: + state = STORE.get(session_id) + if state is None: + raise HTTPException(404, "unknown session — start a new game") + return state + + +# --------------------------------------------------------------------- routes + +@api.post("/api/warm") +def warm() -> dict: + """Wake the Modal containers early (called on page load) so the first + real event doesn't eat a cold start.""" + llm.warm() + comic.warm() + return {"ok": True} + + +@api.post("/api/comic") +def comic_panel(body: ComicBody) -> dict: + """Render the crisis comic from an AI-written image_prompt. Returns + {image_b64: null} when FLUX is unavailable / fails / times out — the + client then simply skips the overlay and opens the dialogue as usual.""" + _get_state(body.session_id) # session must exist + panels = body.image_prompt.strip() + if not panels: + return {"image_b64": None} + # prepend the art style here so the model spends its whole image_prompt + # budget on panel descriptions (else the ~230-char style tag ate the budget + # and the panels truncated to a single panel) + img = comic.generate_comic(f"{prompts.COMIC_STYLE}. {panels}") + return {"image_b64": img} + + +@api.post("/api/new_game") +def new_game() -> dict: + llm.warm() # belt-and-suspenders prewarm at game start + comic.warm() + state = STORE.create() + state.phase = "free_roam" + trace("flow", f"=== NEW GAME {state.session_id[:8]} " + f"({'LIVE ' + os.environ.get('MODAL_URL', '') if os.environ.get('MODAL_URL') else 'MOCK mode'})") + return {"session_id": state.session_id, "state": state.snapshot()} + + +@api.post("/api/next_event") +def next_event(body: SessionBody) -> dict: + state = _get_state(body.session_id) + if state.game_over: + raise HTTPException(409, "quarter is over") + event = events.next_event(state) + return {"event": event, "state": state.snapshot()} + + +@api.post("/api/respond") +def respond(body: RespondBody) -> dict: + state = _get_state(body.session_id) + if body.response_type not in RESPONSE_TYPES: + raise HTTPException(422, "bad response_type") + if state.phase != "crisis" or not state.current_event: + raise HTTPException(409, "no active crisis") + outcome = events.respond(state, body.response_type, body.text.strip()) + return {"outcome": outcome, "state": state.snapshot()} + + +@api.post("/api/presentation_round") +def presentation_round(body: RespondBody) -> dict: + state = _get_state(body.session_id) + if state.phase != "presentation" or state.presentation is None: + raise HTTPException(409, "no active presentation") + round_data = presentation.advance(state, body.response_type, body.text.strip()) + return {"round_data": round_data, "state": state.snapshot()} + + +@api.post("/api/gift") +def gift(body: GiftBody) -> dict: + state = _get_state(body.session_id) + if state.phase != "free_roam": + raise HTTPException(409, "gifts only between crises") + cost = economy.gift_cost(body.tier) + if cost is None: + raise HTTPException(422, "bad tier") + if state.pocket_money < cost: + raise HTTPException(409, "insufficient pocket money") + if body.tier == "coffee": + state.pocket_money -= cost + relationships.coffee_round(state) + result = {"kind": "coffee", "cost": cost} + trace("flow", f"coffee round -$50 -> morale {state.morale}") + else: + if body.npc_id not in NPC_IDS: + raise HTTPException(422, "bad npc_id") + state.pocket_money -= cost + result = relationships.give_gift(state, body.npc_id, cost) + result.update({"kind": "gift", "cost": cost, "npc_id": body.npc_id}) + trace("flow", f"gift {body.tier} -> {body.npc_id} " + f"rel+{result['relationship_delta']}" + f"{' (halved)' if result['halved'] else ''}" + f"{' UNLOCKED' if result['unlocked'] else ''} " + f"-> {state.npc(body.npc_id).relationship}") + return {"result": result, "state": state.snapshot()} + + +@api.post("/api/chat") +def chat(body: ChatBody) -> dict: + state = _get_state(body.session_id) + if state.phase != "free_roam": + raise HTTPException(409, "chat only between crises") + if body.npc_id not in NPC_IDS: + raise HTTPException(422, "bad npc_id") + try: + if body.text.strip(): + result = idle.reply_chat(state, body.npc_id, body.text.strip()) + else: + result = idle.open_chat(state, body.npc_id) + except idle.IdleError as exc: + raise HTTPException(409, str(exc)) + return {"chat": result, "state": state.snapshot()} + + +@api.post("/api/idle") +def idle_roll(body: SessionBody) -> dict: + state = _get_state(body.session_id) + if state.phase != "free_roam": + raise HTTPException(409, "idle moments only between crises") + try: + result = idle.roll_idle(state) + except idle.IdleError as exc: + raise HTTPException(409, str(exc)) + return {"idle": result, "state": state.snapshot()} + + +@api.post("/api/read_email") +def read_email(body: SessionBody) -> dict: + state = _get_state(body.session_id) + try: + email = idle.read_email(state) + except idle.IdleError as exc: + raise HTTPException(409, str(exc)) + return {"email": email, "state": state.snapshot()} + + +@api.post("/api/review") +def review(body: SessionBody) -> dict: + state = _get_state(body.session_id) + if state.phase != "review": + raise HTTPException(409, "quarter not finished") + data = state.review or presentation.quarterly_review(state) + return {"review": data, "state": state.snapshot()} + + +@api.get("/healthz") +def healthz() -> dict: + return {"ok": True} + + +# ------------------------------------------------------------- custom frontend + +api.mount("/static", StaticFiles(directory=ROOT / "static"), name="static") + + +@api.get("/") +@api.get("/game") +def game_page() -> FileResponse: + """Serve the fully custom canvas/DOM game frontend (no Gradio widgets).""" + return FileResponse(ROOT / "static" / "index.html") + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860))) diff --git a/game/__init__.py b/game/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/game/comic.py b/game/comic.py new file mode 100644 index 0000000000000000000000000000000000000000..c0502d5ccebdf33fe63e7d5eb00690bbf3da7f4e --- /dev/null +++ b/game/comic.py @@ -0,0 +1,73 @@ +"""Modal FLUX client for comic-panel image generation. + +If FLUX_URL is set, POSTs the image prompt to the Modal FLUX endpoint and +returns a base64 PNG. Otherwise — or on any failure/timeout — returns None, +and the frontend renders the composed chibi-comic fallback instead. Mirrors the +warm / cold-timeout pattern in llm.py. +""" +from __future__ import annotations + +import os +import threading +import time + +import requests + +from .trace import trace + +FLUX_TIMEOUT = float(os.environ.get("BDS_FLUX_TIMEOUT", "12")) +FLUX_COLD_TIMEOUT = float(os.environ.get("BDS_FLUX_COLD_TIMEOUT", "90")) +_warmed = False + + +def flux_available() -> bool: + return bool(os.environ.get("FLUX_URL")) + + +def _headers() -> dict: + return {"Authorization": "Bearer " + os.environ.get("FLUX_TOKEN", "")} + + +def warm() -> None: + """Boot the FLUX container in the background (page load / new game).""" + if not flux_available() or _warmed: + return + threading.Thread(target=_warm_ping, daemon=True).start() + + +def _warm_ping() -> None: + try: + requests.post(os.environ["FLUX_URL"], json={"warmup": True}, + headers=_headers(), timeout=FLUX_COLD_TIMEOUT + 60) + trace("flux", "warmup ping done") + except requests.RequestException: + pass + + +def generate_comic(prompt: str) -> str | None: + """Return a base64 PNG for the comic, or None → composed fallback.""" + if not flux_available() or not prompt: + return None + global _warmed + timeout = FLUX_TIMEOUT if _warmed else FLUX_COLD_TIMEOUT + t0 = time.time() + try: + resp = requests.post(os.environ["FLUX_URL"], json={"prompt": prompt}, + headers=_headers(), timeout=timeout) + ms = int((time.time() - t0) * 1000) + if resp.status_code == 200: + body = resp.json() + img = body.get("image_b64") + if img: + _warmed = True + trace("flux", f"comic LIVE {ms}ms ({len(img)}b)") + return img + trace("flux", f"comic not-ok {ms}ms: " + f"{str(body.get('error', body))[:120]}") + else: + trace("flux", f"comic HTTP {resp.status_code} {ms}ms") + except requests.Timeout: + trace("flux", f"comic TIMEOUT after {timeout}s") + except requests.RequestException as exc: + trace("flux", f"comic transport error: {str(exc)[:120]}") + return None diff --git a/game/context.py b/game/context.py new file mode 100644 index 0000000000000000000000000000000000000000..38bb774af4b93eae1dc60be583227862958960b5 --- /dev/null +++ b/game/context.py @@ -0,0 +1,52 @@ +"""Global context builder — state → the exact context object in SCHEMAS.md.""" +from __future__ import annotations + +from .schemas import APPROVED_CLIENTS, NPC_IDS +from .state import GameState + + +def build_context(state: GameState) -> dict: + return { + "game_config": { + "company": "Veloura Technologies", + "player_title": "Head of Sales and Partnerships", + "crisis_number": state.crisis_number, + "total_crises": 15, + "tone": "comedic corporate satire — The Office meets Silicon Valley", + "approved_clients": APPROVED_CLIENTS, + }, + "financial_state": { + "revenue": state.revenue, + "target": state.target, + "company_budget": state.company_budget, + "bonuses_issued": state.bonuses_issued, + "total_bonus_spend": state.total_bonus_spend, + "pocket_money": state.pocket_money, + "bribes_accepted": state.bribes_accepted, + }, + "team_state": { + "morale": state.morale, + "npcs": { + n: { + "relationship": s.relationship, + "gifts_received": s.gifts_received, + "mood": s.mood, + "incident_count": s.incident_count, + "personal_situation": s.personal_situation, + "recent_events": s.recent_events[-3:], + } + for n, s in state.npcs.items() + }, + }, + "constraint_state": { + "budget_warning": state.company_budget < 5_000, + "board_scrutiny": state.board_scrutiny, + "consecutive_praise": {n: state.npc(n).consecutive_praise for n in NPC_IDS}, + "consecutive_harsh": state.consecutive_harsh, + "consecutive_fine_whatever": state.consecutive_fine_whatever, + "hr_alert": state.hr_alert, + "extended_presentation": bool( + state.presentation and state.presentation.get("extended")), + }, + "event_log": list(state.event_log), + } diff --git a/game/economy.py b/game/economy.py new file mode 100644 index 0000000000000000000000000000000000000000..62ede9b35872ad30def09f81f66dfbf3ef5bd88a --- /dev/null +++ b/game/economy.py @@ -0,0 +1,129 @@ +"""Revenue, budget, bonuses, pocket money, bribery — MECHANICS.md rules.""" +from __future__ import annotations + +import os + +from .schemas import GIFT_TIERS +from .state import GameState + +BONUS_PER_EVENT_CAP = 5_000 +BONUS_QUARTER_CAP = 20_000 +BONUS_SCRUTINY_THRESHOLD = 3 +SALARY_EVERY = 3 +SALARY_AMOUNT = 1_000 + +# balance is driven by the crisis prompt's revenue anchors (the model follows +# them well); this multiplier on LIVE crisis deltas stays a dormant safety knob +# (1.0 = off) in case the model drifts low again. Mock deltas are not scaled. +REVENUE_SCALE = float(os.environ.get("BDS_REVENUE_SCALE", "1.0")) + + +def scale_revenue(raw: int) -> int: + """Scale a raw live-model revenue delta toward the intended economy.""" + try: + return int(round(raw * REVENUE_SCALE)) + except (TypeError, ValueError): + return 0 + + +def apply_revenue(state: GameState, delta: int) -> int: + """Applies a revenue delta with the zero floor. Returns the applied delta.""" + applied = max(delta, -state.revenue) + state.revenue += applied + return applied + + +def apply_pocket(state: GameState, delta: int) -> int: + applied = max(delta, -state.pocket_money) + state.pocket_money += applied + return applied + + +def salary_tick(state: GameState) -> bool: + """Called once per resolved crisis. Monthly salary advance every 3 crises.""" + state.crises_since_salary += 1 + if state.crises_since_salary >= SALARY_EVERY: + state.crises_since_salary = 0 + state.pocket_money += SALARY_AMOUNT + return True + return False + + +def can_bonus(state: GameState, amount: int) -> bool: + return (state.company_budget >= 5_000 + and amount <= BONUS_PER_EVENT_CAP + and amount <= state.company_budget + and state.total_bonus_spend + amount <= BONUS_QUARTER_CAP) + + +def issue_bonus(state: GameState, npc_id: str, amount: int) -> bool: + if not can_bonus(state, amount): + return False + state.company_budget -= amount + state.total_bonus_spend += amount + state.bonuses_issued += 1 + state.npc(npc_id).relationship = min(100, state.npc(npc_id).relationship + 8) + if state.bonuses_issued > BONUS_SCRUTINY_THRESHOLD: + raise_scrutiny(state) # the board noticed the pattern + return True + + +def gift_cost(tier: str) -> int | None: + return GIFT_TIERS.get(tier) + + +def accept_bribe(state: GameState, offer: int) -> dict: + state.bribes_accepted += 1 + state.pocket_money += offer + consequences = {"scrutiny": None, "hr": False} + if state.bribes_accepted == 2: + if state.board_scrutiny == "low": + state.board_scrutiny = "medium" + consequences["scrutiny"] = state.board_scrutiny + elif state.bribes_accepted >= 3: + state.board_scrutiny = "high" if state.board_scrutiny in ( + "low", "medium") else state.board_scrutiny + state.hr_alert = True + consequences["scrutiny"] = state.board_scrutiny + consequences["hr"] = True + return consequences + + +def decline_bribe(state: GameState) -> None: + state.morale = min(100, state.morale + 5) + + +SCRUTINY_ORDER = ["low", "medium", "high", "critical"] + + +def raise_scrutiny(state: GameState) -> None: + i = SCRUTINY_ORDER.index(state.board_scrutiny) + if i < 2: # critical is only reached via the high-streak rule + state.board_scrutiny = SCRUTINY_ORDER[i + 1] + + +def lower_scrutiny(state: GameState) -> None: + i = SCRUTINY_ORDER.index(state.board_scrutiny) + if i > 0: + state.board_scrutiny = SCRUTINY_ORDER[i - 1] + state.scrutiny_high_streak = 0 + + +def scrutiny_tick(state: GameState) -> None: + """Once per resolved crisis: high maintained >4 crises → critical.""" + if state.board_scrutiny in ("high", "critical"): + state.scrutiny_high_streak += 1 + if state.scrutiny_high_streak > 4: + state.board_scrutiny = "critical" + else: + state.scrutiny_high_streak = 0 + + +def ending_tier(state: GameState) -> str: + if state.revenue >= state.target: + return "hit_target" + if state.revenue > 600_000: + return "above_600k" + if state.revenue >= 300_000: + return "300k_to_600k" + return "below_300k" diff --git a/game/events.py b/game/events.py new file mode 100644 index 0000000000000000000000000000000000000000..b18635c60394aae6d558d29b5b4d4fe7863e351f --- /dev/null +++ b/game/events.py @@ -0,0 +1,381 @@ +"""Event sequencing: slot rolls, special events, crisis resolution.""" +from __future__ import annotations + +import random + +from . import (economy, fallbacks, idle, llm, presentation, prompts, + relationships, validator) +from .schemas import BRIBE_AMOUNTS, NPC_IDS +from .state import PRESENTATION_EVENTS, TOTAL_CRISES, GameState +from .trace import trace + +SPECIAL_BASE_CHANCE = 0.25 +SPECIAL_DROUGHT_CHANCE = 0.60 +DROUGHT_AFTER = 3 + +ARRIVALS = { + "normal": "npc", + "newspaper": "newspaper", + "bribery": "envelope", + "personal": "npc_amber", + "client_emergency": "phone", + "hr": "hr", + "suspicion": "npc_amber", + "romance": "npc_heart", +} + +ROMANCE_ELIGIBLE = 70 # relationship at which an NPC may make a move + + +def _romance_candidate(state: GameState) -> str | None: + """Highest-relationship NPC ready to start a romance, if any.""" + pool = [n for n in NPC_IDS + if state.npc(n).relationship >= ROMANCE_ELIGIBLE + and not state.npc(n).romance_active] + if not pool: + return None + return max(pool, key=lambda n: state.npc(n).relationship) + + +def _any_romance_active(state: GameState) -> bool: + return any(state.npc(n).romance_active for n in NPC_IDS) + + +def next_event(state: GameState) -> dict: + """Advance to the next event slot. Returns the event payload.""" + if state.current_event is not None: + return state.current_event # idempotent: crisis already active + if state.crisis_number >= TOTAL_CRISES: + state.phase = "review" + return {"kind": "review"} + + state.crisis_number += 1 + idle.reset_gap(state) # idle budgets refresh with every new event slot + + if state.crisis_number in PRESENTATION_EVENTS: + state.phase = "presentation" + presentation.start(state) + trace("flow", f"event {state.crisis_number}: PRESENTATION " + f"(presenting={state.presentation['presenting_npc']} " + f"state={state.presentation['npc_state']} " + f"rounds={state.presentation['total_rounds']})") + event = { + "kind": "presentation", + "arrival": "boardroom", + "crisis_number": state.crisis_number, + "final": state.crisis_number == TOTAL_CRISES, + } + state.current_event = event + return event + + kind = _roll_slot(state) + if kind == "normal": + event = _normal_pitch(state) + elif kind == "bribery": + event = _bribery_event(state) + else: + event = _special_event(state, kind) + + event["kind"] = "crisis" + event["special"] = kind if kind != "normal" else None + event["arrival"] = ARRIVALS[kind] + event["crisis_number"] = state.crisis_number + state.current_event = event + state.phase = "crisis" + trace("flow", f"event {state.crisis_number}: {kind} npc={event['affected_npc']} " + f"\"{event['headline']}\" (drought={state.special_drought})") + return event + + +def _roll_slot(state: GameState) -> str: + if state.queued_special: + kind = state.queued_special + state.queued_special = None + state.special_drought = 0 + return kind + chance = (SPECIAL_DROUGHT_CHANCE if state.special_drought >= DROUGHT_AFTER + else SPECIAL_BASE_CHANCE) + if random.random() < chance: + state.special_drought = 0 + if state.hr_alert: + return "hr" + pool = ["newspaper", "bribery", "personal", "client_emergency"] + # one romance at a time; offer it when someone is ready to make a move + if _romance_candidate(state) and not _any_romance_active(state): + pool += ["romance", "romance"] # weight it up once eligible + return random.choice(pool) + state.special_drought += 1 + return "normal" + + +def _pick_npc(state: GameState) -> str: + """Prefer NPCs with fewer incidents; mild bias toward interesting moods.""" + weights = [] + for n in NPC_IDS: + npc = state.npc(n) + w = 4.0 / (1 + npc.incident_count) + if npc.mood not in ("normal", "happy"): + w *= 1.4 + weights.append(w) + return random.choices(NPC_IDS, weights=weights)[0] + + +def _normal_pitch(state: GameState) -> dict: + npc_id = _pick_npc(state) + system, user = prompts.event_prompt(state, f"normal pitch from {npc_id}") + payload = llm.call_validated(state, "event", system, user, + lambda p: validator.validate_event(p, state), + requested_type="normal", npc_id=npc_id) + if payload is None: + state.fallback_count += 1 + payload = fallbacks.event_fallback(state.crisis_number) + payload["affected_npc"] = npc_id + payload["affected_npc"] = payload.get("affected_npc") or npc_id + if payload["affected_npc"] == "player": + payload["affected_npc"] = npc_id + return payload + + +def _special_event(state: GameState, kind: str) -> dict: + romance_npc = _romance_candidate(state) if kind == "romance" else None + flavors = { + "newspaper": "newspaper incident — a quarter disaster reached the press", + "personal": "personal NPC life event bleeding into work", + "client_emergency": "client emergency — a client situation just detonated", + "hr": "formal HR conversation the player must navigate" + + (" — it is about an office romance" if _any_romance_active(state) + else ""), + "suspicion": "the team has collectively noticed the player praising too much; " + "they suspect layoffs or worse", + "romance": ( + f"an office-romance moment: {romance_npc} has clearly developed " + "feelings for the player (their boss) and finally makes a move — a " + "lingering after-hours conversation, a too-personal gift, a " + "confession disguised as a work question. option_a is the player " + "LEANING IN / reciprocating; option_b is the player keeping it " + "warmly professional. Both are first-person player actions. Stay " + "sweet and absurd, never explicit."), + } + system, user = prompts.event_prompt(state, flavors[kind]) + payload = llm.call_validated(state, "event", system, user, + lambda p: validator.validate_event(p, state), + requested_type=kind, romance_npc=romance_npc) + if payload is None: + state.fallback_count += 1 + # type-matched fallback so a newspaper stays a press story, etc. + payload = (fallbacks.romance_fallback(romance_npc) if kind == "romance" + else fallbacks.special_fallback(kind, state.crisis_number)) + if kind == "newspaper": + payload["affected_npc"] = "brad" # usually Brad. It is always Brad. + state.npc("brad").mood = "sheepish" + if kind == "romance" and romance_npc: + payload["affected_npc"] = romance_npc # the resolution needs the target + return payload + + +def _bribery_event(state: GameState) -> dict: + offer = random.choice(BRIBE_AMOUNTS) + state.pending_bribe = offer + return { + "affected_npc": "player", + "category": "financial", + "headline": "An envelope has appeared on your desk", + "intro": f"> no sender. inside: ${offer:,} and a note: 'for your " + "flexibility on the Northpath Solutions terms. there is more " + "where this came from.'", + "option_a": f"ACCEPT — pocket the ${offer:,}. No immediate consequence. Immediate is doing a lot of work there.", + "option_b": "DECLINE — slide it back under the door of reality.", + "urgency": "The envelope is slightly warm. Why is it warm.", + "setup_animation": "bribery_envelope", + "morale_preview": 0, + "bribe_offer": offer, + } + + +# ---------------------------------------------------------------- responding + +def respond(state: GameState, response_type: str, text: str) -> dict: + """Resolve the active crisis with the player's response.""" + event = state.current_event + if not event or event.get("kind") != "crisis": + raise ValueError("no active crisis") + + trace("flow", f"respond [{response_type}]" + + (f" \"{text}\"" if text else "")) + if event.get("special") == "bribery": + outcome = _resolve_bribery(state, response_type, text) + else: + outcome = _resolve_crisis(state, event, response_type, text) + + # bookkeeping shared by every resolution + salary_paid = economy.salary_tick(state) + economy.scrutiny_tick(state) + state.current_event = None + state.phase = "free_roam" + outcome["salary_paid"] = salary_paid + outcome["state_phase"] = state.phase + return outcome + + +def _resolve_crisis(state: GameState, event: dict, response_type: str, + text: str) -> dict: + npc_id = event["affected_npc"] + if npc_id == "player": + npc_id = random.choice(NPC_IDS) + system, user = prompts.crisis_prompt(state, npc_id, event, response_type, text) + payload = llm.call_validated( + state, "crisis", system, user, + lambda p: validator.validate_crisis(p, state, npc_id), + npc_id=npc_id, response_type=response_type, + player_response=text, crisis=event) + if payload is None: + state.fallback_count += 1 + trace("flow", f"FALLBACK outcome for {npc_id} " + f"(#{state.fallback_count} this session)") + payload = fallbacks.crisis_fallback(npc_id, response_type) + payload = validator.validate_crisis(payload, state, npc_id) + + # a loss that hit the $0 floor still deserves its moment + if payload.get("floored_loss"): + payload["consequence"] = (payload["consequence"].rstrip(". ")[:130] + + ". There was nothing left to lose.") + + # apply deltas server-side + morale_before = state.morale + applied_rev = economy.apply_revenue(state, payload["revenue_delta"]) + relationships.apply_morale(state, payload["morale_delta"]) + npc = state.npc(npc_id) + before_rel = npc.relationship + relationships.apply_relationship(state, npc_id, payload["relationship_delta"]) + unlocked = relationships.crossed_unlock(before_rel, npc.relationship) + economy.apply_pocket(state, payload["pocket_money_delta"]) + npc.incident_count += 1 + npc.recent_events.append(payload["log_entry"]) + relationships.update_mood_from_outcome(state, npc_id, payload["animation"]) + trace("flow", f"applied: rev {applied_rev:+,} -> ${state.revenue:,} | " + f"morale {morale_before}->{state.morale} | " + f"rel[{npc_id}] {before_rel}->{npc.relationship} | " + f"mood={npc.mood} anim={payload['animation']}" + + (" | RELATIONSHIP UNLOCKED" if unlocked else "")) + + # guardrails driven by the player's words + tone = relationships.classify_tone(response_type, text) + praise_result = relationships.praise_tick(state, npc_id, tone) + if praise_result: + trace("econ", f"praise guardrail: {praise_result} for {npc_id}") + if praise_result == "suspicion_event": + state.queued_special = "suspicion" + relationships.harsh_tick(state, tone) + if tone == "harsh": + trace("econ", f"harsh tone detected -> rel[{npc_id}] -6, morale -3") + relationships.apply_relationship(state, npc_id, -6) + + if response_type == "quick_fine": + state.consecutive_fine_whatever += 1 + npc.mood = "smug" # their confidence increases. This is worse. + trace("econ", f"FINE WHATEVER #{state.consecutive_fine_whatever} " + "since last presentation") + else: + state.consecutive_fine_whatever = 0 + + if event.get("special") == "newspaper": + state.newspaper_count += 1 + if applied_rev < 0: + economy.raise_scrutiny(state) + trace("econ", f"newspaper handled badly -> scrutiny {state.board_scrutiny}") + + # romance resolution: option_a (or an affectionate typed reply) = leaning in + if event.get("special") == "romance": + pursued = response_type == "option_a" or ( + response_type == "custom" + and validator.ROMANCE_WORDS.search(text or "")) + if pursued: + npc.romance_active = True + relationships.apply_relationship(state, npc_id, 6) + npc.mood = "heart_eyes" + trace("flow", f"romance: {npc_id} is now dating the player " + f"(rel {npc.relationship})") + else: + relationships.apply_relationship(state, npc_id, -4) + trace("flow", f"romance: player kept it professional with {npc_id}") + + # romance has real stakes: crossing 80 while dating draws HR's eye + if (npc.romance_active and npc.relationship > 80 and not state.hr_alert): + state.hr_alert = True + state.queued_special = "hr" + trace("econ", f"romance with {npc_id} crossed 80 -> HR alert queued") + + if payload["special_next_event"]: + state.queued_special = payload["special_next_event"] + trace("flow", f"AI queued special: {payload['special_next_event']}") + + entry = f"Event {state.crisis_number} — {payload['log_entry']}" + state.log(entry) + state.trail(npc_id, payload["log_entry"], applied_rev) + state.boss_title = payload["boss_title"] + + return { + "npc_id": npc_id, + "npc_reaction": payload["npc_reaction"], + "consequence": payload["consequence"], + "revenue_delta": applied_rev, + "animation": payload["animation"], + "boss_title": payload["boss_title"], + "relationship_unlocked": unlocked, + "image_prompt": payload.get("image_prompt"), # comic payoff (optional) + "comic_caption": payload.get("comic_caption"), # caption text over it + } + + +def _resolve_bribery(state: GameState, response_type: str, text: str) -> dict: + offer = state.pending_bribe + state.pending_bribe = 0 + trace("flow", f"bribery resolution [{response_type}] offer=${offer:,}") + name_line = "" + if response_type in ("option_b", "quick_no"): # decline + response_type = "option_b" + elif response_type in ("quick_fine",): # capitulating to a bribe = taking it + response_type = "option_a" + elif response_type in ("quick_explain", "quick_quit"): + response_type = "option_b" # deflecting still means not taking the money + if response_type == "option_a": # accept + consequences = economy.accept_bribe(state, offer) + trace("econ", f"bribe ACCEPTED #{state.bribes_accepted} -> " + f"scrutiny={state.board_scrutiny} hr={state.hr_alert}") + reaction = (f"> ${offer:,} transferred to personal account. the note " + "dissolves. somewhere, a spreadsheet updates.") + consequence = "No immediate consequence. The word immediate is doing a lot of work." + if consequences["hr"]: + consequence = "HR has been CC'd on something. The something is you." + anim = "hr_stamp" if consequences["hr"] else "bribery_envelope" + log = f"An envelope appeared. The player accepted ${offer:,}. Officially, nothing happened." + delta = 0 + elif response_type == "option_b": # decline + economy.decline_bribe(state) + reaction = "> envelope returned. integrity intact. the team somehow respects this through a mechanism nobody can explain." + consequence = "Morale improved. Nobody knows how they knew. They knew." + anim = "npc_grateful" + log = "An envelope appeared. The player declined it. The team respected it, mysteriously." + delta = 0 + else: # counter-offer or any custom response + gained = min(offer, max(0, offer // 2)) + state.pocket_money += gained + state.bribes_accepted += 1 + reaction = (f"> counter received. they laughed, then paid ${gained:,}. " + "respect, of a kind, has been established.") + consequence = "You negotiated with a bribe. The bribe respects you now." + anim = "bribery_envelope" + log = f"An envelope appeared. The player negotiated. ${gained:,} changed pockets." + delta = 0 + entry = f"Event {state.crisis_number} — {log}" + state.log(entry) + state.trail("player", log, delta) + return { + "npc_id": None, + "npc_reaction": reaction + name_line, + "consequence": consequence, + "revenue_delta": 0, + "animation": anim, + "boss_title": state.boss_title, + "relationship_unlocked": False, + } diff --git a/game/fallbacks.py b/game/fallbacks.py new file mode 100644 index 0000000000000000000000000000000000000000..607de113e5a2fb4f02e948a6ddd0bf60505eff8c --- /dev/null +++ b/game/fallbacks.py @@ -0,0 +1,313 @@ +"""Pre-written fallbacks — content from AI_PROMPTS.md. The player must never +see an error: any Modal timeout or validation failure lands here.""" +from __future__ import annotations + +import random + +REACTIONS = { + "brad": { + "positive": "Knew you'd see it, boss. This is why we're a great team. I already told two people.", + "neutral": "Okay. Noted. Circling back. The window's still open by the way. Brad-window.", + "negative": "Wow. Okay. That's a choice. I'm putting this in my book. There's a chapter now.", + }, + "stacey": { + "positive": "Oh thank god. Thank you. I already drafted three apology emails, I'll only send one.", + "neutral": "Right, yes, totally — I'll fix it. I know exactly how. Mostly exactly.", + "negative": "No that's fair. That's completely fair. I'm so sorry. I'll just— yes. Okay.", + }, + "kevin": { + "positive": "Directionally, this validates everything. I'll add a slide. The slide will be green.", + "neutral": "Interesting. The data didn't predict this. I'll adjust the methodology. Quietly.", + "negative": "With respect, the numbers disagree. I'll re-run them until they don't.", + }, + "janet": { + "positive": "THIS is leadership with a point of view. I'm putting it in the newsletter. With a metaphor.", + "neutral": "Fine. But the brand will remember how this felt.", + "negative": "I hear you. The vision doesn't, but I do.", + }, + "derek": { + "positive": "Hm. That's what Margaret would have done. Before the incident.", + "neutral": "Noted. We tried that in 2019. Well. Something like it.", + "negative": "...Understood.", + }, +} + +_KIND_DELTAS = { + "positive": (25_000, 40_000, 2, 2, "npc_happy"), + "neutral": (-5_000, 10_000, 0, 0, "npc_confused"), + "negative": (-40_000, -15_000, -3, -3, "npc_devastated"), +} + +NPC_NAMES = {"brad": "Brad", "stacey": "Stacey", "kevin": "Kevin", + "janet": "Janet", "derek": "Derek"} + + +def kind_for_response(response_type: str) -> str: + if response_type in ("quick_fine",): + return "negative" + if response_type in ("custom", "quick_explain", "quick_quit"): + return "neutral" + return "positive" + + +def crisis_fallback(npc_id: str, response_type: str) -> dict: + kind = kind_for_response(response_type) + lo, hi, morale, rel, anim = _KIND_DELTAS[kind] + delta = random.randint(lo // 1000, hi // 1000) * 1000 + name = NPC_NAMES[npc_id] + sign = "+" if delta >= 0 else "-" + return { + "npc_reaction": REACTIONS[npc_id][kind], + "consequence": f"{name} handled it. Nobody is entirely sure how, and nobody asked.", + "revenue_delta": delta, + "animation": anim, + "boss_title": "Acting Head of Whatever This Is", + "log_entry": f"{name} had a situation. It was handled. {sign}${abs(delta) // 1000}K.", + "morale_delta": morale, + "npc_id": npc_id, + "relationship_delta": rel, + "pocket_money_delta": 0, + "special_next_event": None, + } + + +_EVENT_FALLBACKS = [ + { + "affected_npc": "player", + "category": "professional", + "headline": "The printer has produced something", + "intro": "> inbox: the printer in the kitchen has been printing the same page for twenty minutes. People have seen it. It is a ranking.", + "option_a": "Shred everything and declare a paperless office, effective immediately.", + "option_b": "Pin it to the corkboard and call it radical transparency.", + "urgency": "It is still printing.", + "setup_animation": "npc_confused", + "morale_preview": -3, + }, + { + "affected_npc": "stacey", + "category": "professional", + "headline": "Wrong attachment, right energy", + "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.", + "option_a": "Claim it was an icebreaker initiative and send the rest of the spreadsheet.", + "option_b": "Blame a software glitch nobody can name.", + "urgency": "He has replied TWICE.", + "setup_animation": "npc_crying", + "morale_preview": -4, + }, + { + "affected_npc": "brad", + "category": "external", + "headline": "Mystery package addressed to nobody", + "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.", + "option_a": "Distribute the stress balls. Free is free.", + "option_b": "Mail them back with a strongly worded sticky note.", + "urgency": "Brad's theory has slides.", + "setup_animation": "npc_smug", + "morale_preview": 2, + }, + { + "affected_npc": "kevin", + "category": "personal", + "headline": "It is Kevin's birthday", + "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.", + "option_a": "Emergency cake run on company budget. Backdate the enthusiasm.", + "option_b": "Declare birthdays a Q4 initiative.", + "urgency": "He is wearing the hat.", + "setup_animation": "npc_devastated", + "morale_preview": -5, + }, +] + + +def event_fallback(index: int) -> dict: + return dict(_EVENT_FALLBACKS[index % len(_EVENT_FALLBACKS)]) + + +# type-matched fallbacks so a special's arrival animation never contradicts its +# content (a "newspaper" arrival must not fall back to a birthday, etc.) +_SPECIAL_FALLBACKS = { + "newspaper": { + "affected_npc": "brad", + "category": "external", + "headline": "The press got hold of it", + "intro": "Boss. So. A reporter wrote us up. The headline uses the word " + "'reportedly' four times and there's a photo of me mid-sentence. " + "It's already being shared. I look powerful though.", + "option_a": "Issue a correction so dry nobody finishes reading it.", + "option_b": "Lean in and frame the whole thing as bold market disruption.", + "urgency": "The comment section has discovered us.", + "setup_animation": "npc_hiding", + "morale_preview": -5, + }, + "client_emergency": { + "affected_npc": "stacey", + "category": "professional", + "headline": "A client is on the line, right now", + "intro": "I have Northpath Solutions on hold and they are not happy. " + "Something about a deliverable we may have described as 'basically " + "done'. It was not basically done. It was basically a folder.", + "option_a": "Take the call yourself and promise a recovery plan by Friday.", + "option_b": "Have Stacey stall with enthusiasm while we invent the thing.", + "urgency": "They can hear the hold music looping. So can we.", + "setup_animation": "npc_crying", + "morale_preview": -6, + }, + "personal": { + "affected_npc": "kevin", + "category": "personal", + "headline": "Something is going on with the team", + "intro": "Not a work thing, technically. But it's bleeding into the work " + "thing. There were tears at the printer. The printer is fine. The " + "person is, statistically, also fine. Probably.", + "option_a": "Check in personally and quietly cover their afternoon.", + "option_b": "Declare a surprise team lunch and never address it directly.", + "urgency": "The whole floor is pretending to type.", + "setup_animation": "npc_devastated", + "morale_preview": -4, + }, + "hr": { + "affected_npc": "player", + "category": "professional", + "headline": "HR would like a quick word", + "intro": "> HR has requested a brief, informal, absolutely-not-a-big-deal " + "conversation regarding 'recent patterns'. They have used the " + "phrase 'just to document it'. There is a folder.", + "option_a": "Walk in honest and own whatever this is about.", + "option_b": "Bring your own folder. Establish folder dominance.", + "urgency": "The meeting room blinds are already closed.", + "setup_animation": "npc_suspicious", + "morale_preview": -5, + }, +} + + +def special_fallback(kind: str, index: int) -> dict: + """A coherent fallback whose theme matches the special-event arrival.""" + if kind in _SPECIAL_FALLBACKS: + return dict(_SPECIAL_FALLBACKS[kind]) + return event_fallback(index) + + +def presentation_fallback(round_no: int, last_log: str) -> dict: + if round_no >= 3: + return { + "round": round_no, + "board_tone": "neutral", + "event_referenced": last_log[:150], + "round_difficulty": "standard", + "board_dialogue": "The board has reviewed the quarter so far. Specifically this: " + f"\"{last_log[:120]}\". Give us your closing statement.", + "cumulative_score": 50, + } + return { + "round": round_no, + "board_tone": "neutral", + "event_referenced": last_log[:150], + "round_difficulty": "standard", + "option_a": "Own it completely and pivot to the pipeline.", + "option_b": "Contextualize it as a learning investment.", + "board_dialogue": f"Let's start with this item from the record: \"{last_log[:120]}\". " + "Walk us through it.", + } + + +CHAT_OPENERS = { + "brad": "Boss. Glad you stopped by. I'm working on something big. Can't say what. It's big though.", + "stacey": "Oh! Hi. Everything's under control. I just triple-checked the recipient field on everything. Twice.", + "kevin": "Good timing. The numbers are doing something interesting. Directionally interesting.", + "janet": "I've been thinking about our visual language. We need to talk about it. Not now. But soon.", + "derek": "Hm. You walk the floor now. Interesting.", +} + +CHAT_REPLIES = { + "brad": "Knew you'd get it, boss. This is why I tell people we're tight.", + "stacey": "That actually helps. Thank you. I'll only worry about it a normal amount now.", + "kevin": "Noted. I'll factor that into the model. The model appreciates it.", + "janet": "See, THIS is the kind of dialogue the brand needs internally.", + "derek": "Hm. Noted.", +} + + +def chat_fallback(npc_id: str, opener: bool) -> dict: + return { + "npc_line": (CHAT_OPENERS if opener else CHAT_REPLIES)[npc_id], + "relationship_delta": 0 if opener else 1, + "morale_delta": 0, + } + + +BANTER_LINES = [ + ("brad", "...so I told them, that's not a bug, that's a premium feature. They went quiet. Closing energy."), + ("kevin", "The Q3 numbers are directionally fine. Directionally."), + ("janet", "The font says reliable. We are not a reliable font company."), + ("stacey", "Okay but who do I apologize to if nobody noticed yet?"), + ("derek", "We had a printer like that in 2019. Before the incident."), +] + + +def banter_fallback(index: int) -> dict: + npc_id, line = BANTER_LINES[index % len(BANTER_LINES)] + return {"npc_id": npc_id, "line": line} + + +EAVESDROP_EXCHANGES = [ + [("brad", "Kevin. Buddy. Your chart says we grew 140 percent."), + ("kevin", "The chart is directionally accurate, Brad."), + ("brad", "I put it in the client deck.")], + [("janet", "The newsletter needs a hero image that says resilience."), + ("stacey", "Is that why you sent me forty photos of lighthouses?"), + ("janet", "Forty OPTIONS, Stacey.")], +] + + +def eavesdrop_fallback(index: int) -> dict: + exchange = EAVESDROP_EXCHANGES[index % len(EAVESDROP_EXCHANGES)] + return {"lines": [{"speaker": s, "line": l} for s, l in exchange]} + + +EMAIL_BANK = [ + {"sender": "janet", "subject": "BRAND PULSE — week of now", + "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."}, + {"sender": "kevin", "subject": "Metric of the Day", + "body": "Pipeline velocity is up 31% against a baseline I have defined myself. Methodology available upon request. Please do not request it."}, + {"sender": "system", "subject": "FACILITIES: regarding the printer", + "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."}, +] + + +def email_fallback(index: int) -> dict: + return dict(EMAIL_BANK[index % len(EMAIL_BANK)]) + + +_ROMANCE_INTROS = { + "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.", + "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?", + "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.", + "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.", + "derek": "Hm. You stayed late again. So did I. ...That's all. Unless it isn't. It might not be. Hm.", +} + + +def romance_fallback(npc_id: str) -> dict: + npc_id = npc_id or "stacey" + return { + "affected_npc": npc_id, + "category": "personal", + "headline": f"{NPC_NAMES[npc_id]} has feelings, apparently", + "intro": _ROMANCE_INTROS[npc_id], + "option_a": "Lean in. Say you've felt it too. What's the worst that happens.", + "option_b": "Smile, keep it warm, and steer firmly back to the quarterly numbers.", + "urgency": "The whole floor is pretending not to watch.", + "setup_animation": "heart_float", + "morale_preview": 4, + } + + +def verdict_fallback(tier: str) -> dict: + verdicts = { + "hit_target": "Against every available signal, the number is real. The board has voted to stop asking how.", + "above_600k": "Close. The board has expressed this in an emoji, in Slack, three times. You know the one.", + "300k_to_600k": "The board wants a call. Not a good call. You already know the energy of the call.", + "below_300k": "The board has drafted something. It mentions your continued presence. It is currently unsigned.", + } + return {"verdict": verdicts[tier]} diff --git a/game/idle.py b/game/idle.py new file mode 100644 index 0000000000000000000000000000000000000000..b080d5eca30f3b7007a76748c6345b127fc583d8 --- /dev/null +++ b/game/idle.py @@ -0,0 +1,161 @@ +"""Idle activities between crises: small talk, banter, eavesdrop, emails. + +All AI-generated from live game state; budgets are enforced per roam gap so +idle chat can never become a relationship farm (plan: ±2 rel / ±1 morale per +gap, 1 chat per NPC, 2 chats per gap, 2 turns per chat, 1 ambient roll). +""" +from __future__ import annotations + +import random + +from . import fallbacks, llm, prompts, relationships, validator +from .schemas import NPC_IDS +from .state import GameState +from .trace import trace + +MAX_CHATS_PER_GAP = 2 +MAX_TURNS_PER_CHAT = 2 + + +def reset_gap(state: GameState) -> None: + """Called by events.next_event when a new event slot begins.""" + state.chats_this_gap = {} + state.idle_done_this_gap = False + state.chat_session = None + + +class IdleError(Exception): + """Cap or sequencing violation — maps to HTTP 409.""" + + +# ------------------------------------------------------------------ chat + +def open_chat(state: GameState, npc_id: str) -> dict: + if state.chats_this_gap.get(npc_id, 0) >= 1: + raise IdleError(f"{npc_id} has already been chatted with this gap") + if sum(state.chats_this_gap.values()) >= MAX_CHATS_PER_GAP: + raise IdleError("chat budget for this gap is spent") + state.chats_this_gap[npc_id] = 1 + state.chat_session = {"npc_id": npc_id, "turns": 1} + + system, user = prompts.chat_prompt(state, npc_id, None) + payload, _live = llm.call_model(state, "chat", system, user, npc_id=npc_id) + payload = validator.validate_chat(payload, state, npc_id) if payload else None + if payload is None: + state.fallback_count += 1 + payload = fallbacks.chat_fallback(npc_id, opener=True) + trace("flow", f"chat open [{npc_id}] \"{payload['npc_line'][:60]}\"") + # openers never move stats — only the player's reply can + return {"npc_id": npc_id, "npc_line": payload["npc_line"], + "can_reply": True} + + +def reply_chat(state: GameState, npc_id: str, text: str) -> dict: + session = state.chat_session + if not session or session["npc_id"] != npc_id: + raise IdleError("no open chat with this npc") + if session["turns"] >= MAX_TURNS_PER_CHAT: + raise IdleError("this chat is over — they have work to pretend to do") + session["turns"] += 1 + + system, user = prompts.chat_prompt(state, npc_id, text) + payload, _live = llm.call_model(state, "chat", system, user, + npc_id=npc_id, player_text=text) + payload = validator.validate_chat(payload, state, npc_id) if payload else None + if payload is None: + state.fallback_count += 1 + payload = fallbacks.chat_fallback(npc_id, opener=False) + + # micro-effects, guardrails included + tone = relationships.classify_tone("custom", text) + rel_delta = payload["relationship_delta"] + if tone == "harsh": + rel_delta = min(rel_delta, -1) + applied = relationships.apply_relationship(state, npc_id, rel_delta) + relationships.apply_morale(state, payload["morale_delta"]) + praise_result = relationships.praise_tick(state, npc_id, tone) + if praise_result == "suspicion_event": + state.queued_special = "suspicion" + if praise_result: + trace("econ", f"praise guardrail via chat: {praise_result} for {npc_id}") + trace("flow", f"chat reply [{npc_id}] rel{applied:+d} " + f"-> {state.npc(npc_id).relationship} " + f"\"{payload['npc_line'][:60]}\"") + state.chat_session = None + return {"npc_id": npc_id, "npc_line": payload["npc_line"], + "relationship_delta": applied, "can_reply": False} + + +# ------------------------------------------------------------- ambient roll + +def _eligible_pairs(state: GameState) -> list[tuple[str, str]]: + talked = [n for n in NPC_IDS if state.npc(n).incident_count > 0 + or state.npc(n).mood != "normal"] + pool = talked if len(talked) >= 2 else NPC_IDS + pairs = [] + for i, a in enumerate(pool): + for b in pool[i + 1:]: + pairs.append((a, b)) + return pairs + + +def roll_idle(state: GameState) -> dict: + if state.idle_done_this_gap: + raise IdleError("ambient moment already happened this gap") + state.idle_done_this_gap = True + + roll = random.random() + if state.pending_email is None and roll < 0.25: + kind = "email" + elif roll < 0.60: + kind = "banter" + else: + kind = "eavesdrop" + + if kind == "email": + system, user = prompts.email_prompt(state) + payload, _live = llm.call_model(state, "email", system, user) + payload = validator.validate_email(payload, state) if payload else None + if payload is None: + state.fallback_count += 1 + payload = fallbacks.email_fallback(state.crisis_number) + state.pending_email = payload + trace("flow", f"idle: email from {payload['sender']} " + f"\"{payload['subject']}\"") + return {"kind": "email_waiting"} + + if kind == "eavesdrop": + pair = random.choice(_eligible_pairs(state)) + system, user = prompts.eavesdrop_prompt(state, *pair) + payload, _live = llm.call_model(state, "eavesdrop", system, user, + pair=pair) + payload = (validator.validate_eavesdrop(payload, state, pair) + if payload else None) + if payload is None: + state.fallback_count += 1 + payload = fallbacks.eavesdrop_fallback(state.crisis_number) + for i, entry in enumerate(payload["lines"]): + entry["speaker"] = pair[i % 2] + trace("flow", f"idle: eavesdrop {pair[0]}+{pair[1]} " + f"({len(payload['lines'])} lines)") + return {"kind": "eavesdrop", "lines": payload["lines"]} + + system, user = prompts.banter_prompt(state) + payload, _live = llm.call_model(state, "banter", system, user) + payload = validator.validate_banter(payload, state) if payload else None + if payload is None: + state.fallback_count += 1 + payload = fallbacks.banter_fallback(state.crisis_number) + trace("flow", f"idle: banter [{payload['npc_id']}] " + f"\"{payload['line'][:60]}\"") + return {"kind": "banter", "npc_id": payload["npc_id"], + "line": payload["line"]} + + +def read_email(state: GameState) -> dict: + if state.pending_email is None: + raise IdleError("no email waiting") + email = state.pending_email + state.pending_email = None + trace("flow", f"email read: \"{email['subject']}\"") + return email diff --git a/game/llm.py b/game/llm.py new file mode 100644 index 0000000000000000000000000000000000000000..85f2f3a99547f8fa446819fa4d2b39aa604b5a3c --- /dev/null +++ b/game/llm.py @@ -0,0 +1,433 @@ +"""Modal client + offline mock model. + +If MODAL_URL is set, calls the Modal llama.cpp endpoint (8s timeout, no retry). +Otherwise — or on any failure — a deterministic-enough mock generates +schema-valid, in-character output so the game is fully playable offline. +The caller still runs everything through validator.py either way. +""" +from __future__ import annotations + +import os +import random +import threading +import time + +import requests + +from . import fallbacks +from .context import build_context +from .schemas import SCHEMA_BY_CALL_TYPE +from .state import GameState +from .trace import trace, trace_payload + +# 8s in production (TECHNICAL.md); raise via env for slow local CPU inference +TIMEOUT_S = float(os.environ.get("BDS_LLM_TIMEOUT", "8")) +# the FIRST call to a cold Modal container waits longer (boot + model load) +COLD_TIMEOUT = float(os.environ.get("BDS_COLD_TIMEOUT", "55")) +_warmed = False # flips true after the first successful live response + + +def modal_available() -> bool: + return bool(os.environ.get("MODAL_URL")) + + +def warm() -> None: + """Fire a throwaway request so Modal boots the container in the background + (called on page load / new game, long before the first real event).""" + if not modal_available() or _warmed: + return + threading.Thread(target=_warm_ping, daemon=True).start() + + +def _warm_ping() -> None: + try: + requests.post( + os.environ["MODAL_URL"], + json={"call_type": "verdict", "system_prompt": "warmup", + "user_prompt": "warmup", "context": {}, + "schema": SCHEMA_BY_CALL_TYPE["verdict"]}, + headers={"Authorization": "Bearer " + os.environ.get("MODAL_TOKEN", "")}, + timeout=COLD_TIMEOUT + 60) + trace("llm", "warmup ping done") + except requests.RequestException: + pass + + +def call_model(state: GameState, call_type: str, system_prompt: str, + user_prompt: str, **mock_kwargs) -> tuple[dict | None, bool]: + """Returns (payload, used_live_model). payload None means caller must + use fallbacks.py.""" + if modal_available(): + global _warmed + timeout = TIMEOUT_S if _warmed else COLD_TIMEOUT # first call waits for boot + t0 = time.time() + try: + # MODAL_URL is the full endpoint URL (Modal gives each function + # its own URL; the local dev server uses .../generate) + resp = requests.post( + os.environ["MODAL_URL"], + json={ + "call_type": call_type, + "system_prompt": system_prompt, + "user_prompt": user_prompt, + "context": build_context(state), + "schema": SCHEMA_BY_CALL_TYPE[call_type], + }, + headers={"Authorization": "Bearer " + os.environ.get("MODAL_TOKEN", "")}, + timeout=timeout, + ) + ms = int((time.time() - t0) * 1000) + if resp.status_code == 200: + body = resp.json() + if body.get("ok") and isinstance(body.get("data"), dict): + _warmed = True # container is hot now → short timeouts + data = body["data"] + # the live model lowballs revenue — scale crisis deltas into + # the intended economy (mock deltas are already balanced) + if call_type == "crisis" and "revenue_delta" in data: + from . import economy + data["revenue_delta"] = economy.scale_revenue( + data["revenue_delta"]) + trace("llm", f"{call_type} LIVE {ms}ms") + trace_payload(data) + return data, True + trace("llm", f"{call_type} endpoint not-ok {ms}ms: " + f"{str(body.get('error', body))[:160]}") + else: + trace("llm", f"{call_type} HTTP {resp.status_code} {ms}ms") + except requests.Timeout: + trace("llm", f"{call_type} TIMEOUT after {timeout}s") + except requests.RequestException as exc: + trace("llm", f"{call_type} transport error: {str(exc)[:120]}") + return None, False # live mode: failure → real fallback path + payload = _mock(state, call_type, **mock_kwargs) + trace("llm", f"{call_type} MOCK") + return payload, False + + +def call_validated(state: GameState, call_type: str, system_prompt: str, + user_prompt: str, validate, **mock_kwargs) -> dict | None: + """Call the model and validate. If the model produced a payload that fails + validation, retry ONCE with a corrective nudge built from the reject reason + — a single model slip (a repeated round, third-person intro) shouldn't force + a fallback. Transport failures (timeouts) skip the retry (warm/cold timeout + + pre-warm handle those). Returns a validated payload, or None for fallback. + """ + payload, _live = call_model(state, call_type, system_prompt, user_prompt, + **mock_kwargs) + if payload is None: + return None # transport failure → straight to fallback + ok = validate(payload) + if ok is not None: + return ok + from . import validator # lazy: avoid import cycle + reason = getattr(validator, "LAST_REJECT", None) or "it broke the required format" + nudge = (user_prompt + "\n\nIMPORTANT: your previous reply was rejected " + f"because {reason}. Write a brand-new, corrected version that fixes " + "exactly that. Do not repeat your previous reply.") + trace("llm", f"{call_type} retry after reject: {reason}") + payload2, _live2 = call_model(state, call_type, system_prompt, nudge, + **mock_kwargs) + if payload2 is None: + return None + return validate(payload2) + + +# ---------------------------------------------------------------- mock model + +_BOSS_TITLES = [ + "VP of Explaining Brad", "Director of Damage Adjacent", + "Chief Apology Architect", "Head of Saying It's Fine", + "Interim Adult In The Room", "Senior Crisis Sommelier", + "Director of Plausible Deniability", "Head of Vibes, Acting", + "Chief Brad Containment Officer", "VP of Unscheduled Honesty", +] + +_CRISIS_TEMPLATES = { + "brad": [ + ("Brad promised {client} a feature that does not exist yet", + "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.", + "Tell them Friday means a Friday, conceptually, in the future.", + "Have engineering 'demo' a screen recording of a competitor's product.", + "They booked a launch party. Catered."), + ("Brad went on a podcast nobody approved", + "So the episode dropped. I said some numbers. The numbers were aspirational. The host called me a disruptor, so, win?", + "Issue a correction calling the numbers 'directional'.", + "Book Brad on a second podcast to walk back the first podcast.", + "{client} has listened. Twice."), + ], + "stacey": [ + ("Stacey sent the internal pricing sheet to {client}", + "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'.", + "Claim the document was a decoy from a security drill.", + "Honor the real prices and call it a loyalty discount.", + "They replied with a thumbs up. Just the thumbs up."), + ("Stacey's follow-up arrived before the first email", + "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.", + "Send the original email now and pretend time is a circle.", + "Apologize for the apology, creating an apology loop.", + "She has a flowchart of the situation. It loops."), + ], + "kevin": [ + ("Kevin's chart reached the client and it adds to 140 percent", + "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.", + "Tell them the extra 40 percent is forward-looking momentum.", + "Resend the chart with the axis removed entirely.", + "Their analyst has tweeted the chart."), + ("Kevin cited a statistic on a client call that does not exist", + "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.", + "Commission a real survey, results due never.", + "Define 'CTO' broadly enough that it's true.", + "They put it in THEIR deck."), + ], + "janet": [ + ("Janet rebranded the product overnight", + "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.", + "Roll it back and tell Janet the market 'wasn't ready'.", + "Keep the rebrand and update four hundred client contracts.", + "{client} just asked who 'Veloura² ' is."), + ("Janet's Substack mentioned a client by feel", + "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?", + "Have Janet write a flattering follow-up about a fictional company.", + "Take the post down, igniting Janet's vision discourse.", + "The post is 'resonating'."), + ], + "derek": [ + ("Derek approved something nobody knew he could approve", + "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.", + "Unwind the approval and find out what the form was.", + "Let it ride. Derek has never been wrong. Probably.", + "The vendor sent a fruit basket. It's addressed to Derek."), + ("Derek has been marking meetings as 'attended in spirit'", + "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.", + "Institute mandatory camera-on, radicalizing Derek.", + "Tell the client Derek is a strategic silent presence.", + "He was in the building. Nobody knows where."), + ], +} + +_REACTION_BY_KIND = { + "great": { + "brad": "BOSS. That's a closer move. I'm screenshotting this for the book. Chapter one: us.", + "stacey": "Oh that's— that's actually perfect. I can fix everything with that. Sending now. Thank you.", + "kevin": "Huh. The data did not predict that, but I'll backfill the model. Narrative momentum: green.", + "janet": "Okay. OKAY. That's a brand moment. I felt that. The client will feel that.", + "derek": "Hm. Bold. Margaret tried that once. It worked, that time.", + }, + "good": { + "brad": "Solid call boss. Not the Brad play, but solid. I'll spin it. Spinning is closing.", + "stacey": "Yes — okay, yes, I can work with that. Drafting it now. Carefully. Triple-checking the recipient.", + "kevin": "Acceptable. I'll annotate the deck accordingly. The footnote will be small.", + "janet": "Fine. It's not the vision but it's... adjacent to the vision. I'll make it feel intentional.", + "derek": "Noted.", + }, + "bad": { + "brad": "Oof. Okay. The client's gonna feel that one. I'll soften it with energy. So much energy.", + "stacey": "Oh no. Okay. I mean — you're the boss. I'll send it. I'll start apologizing in advance.", + "kevin": "I want it logged that the data disagreed. The data and I are aligned on this.", + "janet": "This is how brands die, but sure. I'll execute it. Minimally.", + "derek": "...As you wish. We did this in 2019. Hm.", + }, + "capitulate": { + "brad": "YES. Boss said go. Boss said GO. I'm already calling them. This is the Brad timeline now.", + "stacey": "Oh. Really? Okay! I mean, if you're sure. I'll do exactly the thing. Exactly as described.", + "kevin": "Excellent. Proceeding precisely as proposed. The model says this ends well for approximately me.", + "janet": "Approved?! The vision is ALIVE. I'm updating everything. Everything is so updated.", + "derek": "Very well. For the record, I proposed it knowing you would say no. Hm.", + }, +} + +_CONSEQUENCES = { + "great": ["{client} signed an expanded scope by end of day.", + "The client laughed, then signed. Mostly in that order.", + "It worked. Nobody is more surprised than the team."], + "good": ["The situation stabilized. Stabilized is the new thriving.", + "{client} accepted the explanation with minor side-eye.", + "Contained. A small invoice for 'goodwill flowers' will appear."], + "bad": ["{client} asked for a 'recalibration call'. It's 90 minutes.", + "It leaked internally. The kitchen knows. The kitchen talks.", + "The fix created a smaller, more personal problem."], + "capitulate": ["It went exactly as proposed and exactly as badly as expected.", + "{client} is 'pausing the relationship to reflect'.", + "Legal-adjacent emails were exchanged. Nobody won."], +} + + +def _creativity_score(text: str) -> int: + """0-2: crude proxy for specificity/effort of a custom response.""" + t = (text or "").strip() + if len(t) < 15: + return 0 + score = 1 + if len(t) > 60 and any(c.isupper() for c in t) and ( + "," in t or "." in t[:-1] or "—" in t): + score = 2 + return score + + +def _mock(state: GameState, call_type: str, **kw) -> dict: + rng = random.Random() + client = rng.choice(build_context(state)["game_config"]["approved_clients"]) + + if call_type == "crisis": + npc_id = kw["npc_id"] + rt = kw["response_type"] + text = kw.get("player_response", "") + if rt == "quick_fine": + kind, lo, hi = "capitulate", -150_000, -60_000 + elif rt in ("option_a", "option_b"): + kind, lo, hi = rng.choice([("good", -25_000, 55_000), + ("bad", -70_000, 15_000)]) + elif rt == "custom": + c = _creativity_score(text) + kind, lo, hi = [("bad", -60_000, 5_000), ("good", -15_000, 60_000), + ("great", 25_000, 140_000)][c] + elif rt == "quick_quit": + kind, lo, hi = "good", -15_000, 25_000 + else: # quick_no / quick_explain + kind, lo, hi = "good", -20_000, 40_000 + delta = rng.randint(lo // 1000, hi // 1000) * 1000 + morale = {"great": rng.randint(4, 9), "good": rng.randint(0, 4), + "bad": rng.randint(-8, -2), "capitulate": rng.randint(-15, -8)}[kind] + rel = {"great": rng.randint(6, 12), "good": rng.randint(2, 6), + "bad": rng.randint(-12, -5), "capitulate": rng.randint(3, 8)}[kind] + anim = {"great": "npc_celebrating", "good": "npc_happy", + "bad": rng.choice(["npc_devastated", "npc_angry", "npc_confused"]), + "capitulate": "npc_smug"}[kind] + if delta <= -80_000: + anim = "disaster_flash" + elif delta >= 150_000: + anim = "revenue_rain" + name = fallbacks.NPC_NAMES[npc_id] + sign = "+" if delta >= 0 else "-" + return { + "npc_reaction": _REACTION_BY_KIND[kind][npc_id], + "consequence": rng.choice(_CONSEQUENCES[kind]).format(client=client), + "revenue_delta": delta, + "animation": anim, + "boss_title": rng.choice(_BOSS_TITLES), + "log_entry": f"{name}: {kw['crisis'].get('headline', 'a situation')[:80]}. " + f"{sign}${abs(delta) // 1000}K.", + "morale_delta": morale, + "npc_id": npc_id, + "relationship_delta": rel, + "pocket_money_delta": 0, + "special_next_event": None, + } + + if call_type == "event": + requested = kw.get("requested_type") + if requested == "normal" and kw.get("npc_id"): + npc_id = kw["npc_id"] + used = " ".join(state.event_log) + pool = [t for t in _CRISIS_TEMPLATES[npc_id] + if t[0].split(" ", 1)[1][:25] not in used] + head, intro, a, b, urgency = rng.choice(pool or _CRISIS_TEMPLATES[npc_id]) + return { + "affected_npc": npc_id, + "category": "professional", + "headline": head.format(client=client)[:80], + "intro": intro.format(client=client)[:200], + "option_a": a.format(client=client)[:150], + "option_b": b.format(client=client)[:150], + "urgency": urgency.format(client=client)[:80], + "setup_animation": "npc_confused", + "morale_preview": rng.randint(-5, 2), + } + if kw.get("requested_type") == "romance": + return fallbacks.romance_fallback(kw.get("romance_npc")) + # special event: rotate through the safe fallback bank + bribery synth + ev = fallbacks.event_fallback(len(state.event_log) + rng.randint(0, 3)) + return ev + + if call_type in ("presentation_round", "presentation_closing"): + round_no = kw["round_no"] + last = state.event_log[-1] if state.event_log else "an uneventful quarter, allegedly" + pick = rng.choice(state.event_log[-6:]) if state.event_log else last + tone = {"low": "warm", "medium": "neutral", + "high": "concerned", "critical": "alarmed"}[state.board_scrutiny] + if state.morale < 20: + tone = "alarmed" + if call_type == "presentation_round": + questions = [ + f"We'd like to begin with this item from the record: \"{pick[:110]}\". Walk us through the thinking, if thinking occurred.", + f"The record contains the phrase \"{pick[:100]}\". The board has read it several times. Explain.", + ] + return { + "round": round_no, + "board_tone": tone, + "event_referenced": pick[:150], + "round_difficulty": {"warm": "easy", "neutral": "standard", + "concerned": "hard", "alarmed": "brutal"}[tone], + "option_a": "Own it fully and redirect to the revenue trend.", + "option_b": "Reframe it as deliberate culture-building.", + "board_dialogue": questions[(round_no - 1) % 2], + } + # closing: score from transcript quality + revenue position + transcript = kw.get("transcript", []) + base = 42 + sum(_creativity_score(t.get("player_response", "")) * 6 + for t in transcript if t.get("player_response")) + rev_factor = max(-15, min(15, int(25 * (state.revenue / state.target + - 0.45)))) + score = max(0, min(100, base + rev_factor + rng.randint(-8, 8))) + if round_no == 4: + body = ("One last thing. Off the record, which is a thing boards say " + "before remembering everything. What would your team say it " + "is like to work for you this quarter?") + else: + body = (f"The board has heard enough context. Including \"{pick[:90]}\". " + "Give us your closing statement.") + return { + "round": round_no, + "board_tone": tone, + "event_referenced": pick[:150], + "round_difficulty": "standard", + "board_dialogue": body, + "cumulative_score": score, + } + + if call_type == "chat": + npc_id = kw["npc_id"] + if kw.get("player_text"): + line = fallbacks.CHAT_REPLIES[npc_id] + rel = rng.choice([0, 1, 1]) + else: + mood_lines = { + "smug": "Things are going extremely my way today, boss. Ask me how.", + "sad": "I'm fine. The week is just... a lot of week.", + "devastated": "I don't want to talk about the thing. Okay, one question about the thing.", + "suspicious": "You're checking in a lot lately. Should I be updating my resume, or...?", + "grateful": "Hey — thanks again. For the thing. You know the thing.", + } + line = mood_lines.get(state.npc(npc_id).mood, + fallbacks.CHAT_OPENERS[npc_id]) + rel = 0 + return {"npc_line": line, "relationship_delta": rel, "morale_delta": 0} + + if call_type == "banter": + return fallbacks.banter_fallback(rng.randint(0, 9)) + + if call_type == "eavesdrop": + pair = kw.get("pair", ("brad", "kevin")) + ex = fallbacks.eavesdrop_fallback(rng.randint(0, 9)) + # remap speakers onto the requested pair so validation passes + for i, entry in enumerate(ex["lines"]): + entry["speaker"] = pair[i % 2] + return ex + + if call_type == "email": + return fallbacks.email_fallback(rng.randint(0, 9)) + + if call_type == "verdict": + tier = kw["tier"] + pick = (random.choice(state.event_log) if state.event_log + else "the quarter") + verdicts = { + "hit_target": f"One million dollars, despite the entry reading \"{pick[:90]}\". The board has voted to stop asking how.", + "above_600k": f"Close. The board re-read \"{pick[:90]}\" and sent the same emoji in Slack, three times. You know the one.", + "300k_to_600k": f"The board wants a call. The agenda is one line and the line is \"{pick[:80]}\".", + "below_300k": f"The board has drafted something regarding your continued presence. Exhibit A reads: \"{pick[:80]}\".", + } + return {"verdict": verdicts[tier][:300]} + + raise ValueError(f"unknown call type {call_type}") diff --git a/game/presentation.py b/game/presentation.py new file mode 100644 index 0000000000000000000000000000000000000000..362761d2720d2cdd8914d70ce4565e07d994e5cc --- /dev/null +++ b/game/presentation.py @@ -0,0 +1,257 @@ +"""Stakeholder presentations — PRESENTATION_SYSTEM.md.""" +from __future__ import annotations + +import random + +from . import economy, fallbacks, llm, prompts, validator +from .state import TOTAL_CRISES, GameState +from .trace import trace + + +def start(state: GameState) -> None: + extended = state.scrutiny_high_streak >= 3 or state.morale < 20 + presenting_npc, npc_state = _presence(state) + # pre-assign DISTINCT logged events as per-round topics. Left to its own + # devices the model re-asks the same question every round; pinning each + # option round to a different event (and telling it what's already covered) + # is what actually stops the repeats. + topics = list(state.event_log) + random.shuffle(topics) + state.presentation = { + "round": 0, + "total_rounds": 4 if extended else 3, + "extended": extended, + "transcript": [], + "presenting_npc": presenting_npc, + "npc_state": npc_state, + "wrong_slide_pending": npc_state == "romance", + "final": state.crisis_number == TOTAL_CRISES, + "score": None, + "topics": topics, + } + + +def _presence(state: GameState) -> tuple[str, str]: + """Pick the presenting NPC and their state per PRESENTATION_SYSTEM.md.""" + for npc_id, npc in state.npcs.items(): + if npc.romance_active: + return npc_id, "romance" + for npc_id, npc in state.npcs.items(): + if npc.personal_situation: + return npc_id, "grief" + for npc_id, npc in state.npcs.items(): + if npc.consecutive_praise >= 2: + return npc_id, "overprepared" + for npc_id, npc in state.npcs.items(): + if npc.relationship < 30: + return npc_id, "bare_minimum" + for npc_id, npc in state.npcs.items(): + if npc.relationship > 65 and npc.gifts_received > 0: + return npc_id, "advocate" + return "kevin", "normal" # Kevin always has slides. Kevin IS slides. + + +_PRESENCE_NOTES = { + "romance": "The presenting NPC is romantically involved with the player. " + "Their deck contains a wrong slide: a photo of the player with " + "hand-drawn hearts. The board has seen it. Round 1 is about it.", + "grief": "The presenting NPC is going through something personal. Grey " + "slides, melancholy titles, trailing off mid-sentence. The board " + "may ask if the team is okay before asking about numbers.", + "overprepared": "The presenting NPC has received excessive praise and " + "produced far more slides than requested. They will not " + "be redirected easily.", + "bare_minimum": "The presenting NPC was treated harshly this quarter. " + "Three slides where eight were expected. One-sentence " + "answers. The board notices the energy.", + "advocate": "The presenting NPC has a strong relationship with the player. " + "Their section is unusually strong and advocates for the " + "player's leadership unprompted.", + "normal": "The presenting NPC prepared the slides. The slides are wrong " + "in the normal way: confidently.", +} + + +def advance(state: GameState, response_type: str, text: str) -> dict: + """Record the player's answer (if any) and produce the next round — + or the final outcome after the last round.""" + p = state.presentation + if p is None: + raise ValueError("no active presentation") + + if p["round"] > 0: + if not text and not response_type: + # repeated "start" call (client retry/double-fire): re-serve the + # current round instead of advancing on an empty answer + if p.get("last_round"): + trace("flow", f"presentation round {p['round']} re-served " + "(duplicate start call)") + return p["last_round"] + trace("flow", f"presentation answer r{p['round']} [{response_type}]" + + (f" \"{text}\"" if text else "")) + p["transcript"][-1]["player_response"] = text or response_type + + if p["round"] >= p["total_rounds"]: + return _finish(state) + + p["round"] += 1 + round_no = p["round"] + closing = round_no >= 3 + call_type = "presentation_closing" if closing else "presentation_round" + + # round 1 & 2 each get a distinct assigned topic; closing rounds synthesize + topics = p.get("topics") or [] + covered = topics[:round_no - 1] + topic = topics[round_no - 1] if (not closing and round_no - 1 < len(topics)) \ + else None + system, user = prompts.presentation_prompt( + state, round_no, p["total_rounds"], p["transcript"], + _PRESENCE_NOTES[p["npc_state"]], topic=topic, covered=covered) + prev_dialogues = tuple(t["board_dialogue"] for t in p["transcript"]) + payload = llm.call_validated( + state, call_type, system, user, + lambda pl: validator.validate_presentation(pl, state, round_no, + closing, prev_dialogues), + round_no=round_no, transcript=p["transcript"]) + if payload is None: + state.fallback_count += 1 + trace("flow", f"FALLBACK presentation round {round_no} " + f"(#{state.fallback_count} this session)") + last = state.event_log[-1] if state.event_log else "the quarter so far" + payload = fallbacks.presentation_fallback(round_no, last) + trace("flow", f"board r{round_no}/{p['total_rounds']} tone={payload['board_tone']} " + f"diff={payload['round_difficulty']} " + f"ref=\"{str(payload['event_referenced'])[:60]}\"") + + p["transcript"].append({ + "round": round_no, + "board_dialogue": payload["board_dialogue"], + "player_response": None, + }) + if closing and "cumulative_score" in payload: + p["score"] = payload["cumulative_score"] + + wrong_slide = p["wrong_slide_pending"] and round_no == 1 + if wrong_slide: + p["wrong_slide_pending"] = False + + p["last_round"] = { + "kind": "round", + "round": round_no, + "total_rounds": p["total_rounds"], + "board_tone": payload["board_tone"], + "board_dialogue": payload["board_dialogue"], + "option_a": payload.get("option_a"), + "option_b": payload.get("option_b"), + "input_only": closing, + "presenting_npc": p["presenting_npc"], + "npc_state": p["npc_state"], + "wrong_slide": wrong_slide, + "is_last_round": round_no >= p["total_rounds"], + } + return p["last_round"] + + +def _finish(state: GameState) -> dict: + p = state.presentation + score = p["score"] if p["score"] is not None else 50 + + # sanity floor: the model scores conservatively (~50) even for strong + # answers, so substantive answers earn a rising baseline — this is what + # lets good presentations actually swing positive instead of netting zero. + answers = [t.get("player_response") or "" for t in p["transcript"]] + substantive = sum(1 for a in answers if len(a) >= 30) + floor = min(72, 40 + 11 * substantive) + if score < floor: + trace("vald", f"score floor: model said {score}, {substantive} " + f"substantive answers -> floor {floor}") + score = floor + + # round 4 self-awareness adjustment + if p["total_rounds"] == 4 and p["transcript"]: + final_answer = p["transcript"][-1].get("player_response") or "" + if len(final_answer) > 60: + state.morale = min(100, state.morale + 5) + else: + state.morale = max(0, state.morale - 5) + + swing = int((score - 50) / 50 * 200_000) + applied = economy.apply_revenue(state, swing) + budget_unlock = 0 + if score >= 70: + budget_unlock = 10_000 + state.company_budget += budget_unlock + if score >= 75: + economy.lower_scrutiny(state) + elif score <= 35: + economy.raise_scrutiny(state) + if score >= 60: + state.morale = min(100, state.morale + 4) + elif score <= 40: + state.morale = max(0, state.morale - 6) + + titles = { + (75, 101): "Quarterly Survivor, Decorated", + (50, 75): "Presenter of Acceptable Truths", + (25, 50): "Director of Damage Adjacent", + (0, 25): "Subject of a Drafted Document", + } + for (lo, hi), title in titles.items(): + if lo <= score < hi: + state.boss_title = title + break + + sign = "+" if applied >= 0 else "-" + log = (f"Stakeholder presentation at event {state.crisis_number}: " + f"scored {score}/100. {sign}${abs(applied) // 1000}K.") + state.log(f"Event {state.crisis_number} — {log}") + state.trail("board", log, applied) + + final = p["final"] + trace("flow", f"presentation DONE: score={score} swing={applied:+,} " + f"budget+{budget_unlock} scrutiny={state.board_scrutiny} " + f"morale={state.morale}") + state.presentation = None + state.current_event = None + state.phase = "review" if final else "free_roam" + + return { + "kind": "outcome", + "score": score, + "revenue_delta": applied, + "budget_unlock": budget_unlock, + "board_scrutiny_public": state.board_scrutiny in ("high", "critical"), + "boss_title": state.boss_title, + "final": final, + } + + +def quarterly_review(state: GameState) -> dict: + tier = economy.ending_tier(state) + system, user = prompts.verdict_prompt(state, tier) + payload, _live = llm.call_model(state, "verdict", system, user, tier=tier) + payload = validator.validate_verdict(payload) if payload else None + if payload is None: + state.fallback_count += 1 + trace("flow", "FALLBACK verdict") + payload = fallbacks.verdict_fallback(tier) + trace("flow", f"QUARTER OVER: tier={tier} revenue=${state.revenue:,} " + f"fallbacks={state.fallback_count} morale={state.morale}") + + highlights = sorted(state.paper_trail, key=lambda e: abs(e["delta"]), + reverse=True)[:5] + review = { + "tier": tier, + "final_revenue": state.revenue, + "target": state.target, + "gap": state.revenue - state.target, + "boss_title": state.boss_title, + "crises_survived": state.crisis_number, + "press_disasters": state.newspaper_count, + "highlights": highlights, + "verdict": payload["verdict"], + } + state.review = review + state.game_over = True + state.phase = "review" + return review diff --git a/game/prompts.py b/game/prompts.py new file mode 100644 index 0000000000000000000000000000000000000000..7e09a35780166ee77b7b94a230af48718f151ddb --- /dev/null +++ b/game/prompts.py @@ -0,0 +1,408 @@ +"""System prompt templates. Text lives in AI_PROMPTS.md — keep in sync.""" +from __future__ import annotations + +import json + +from .context import build_context +from .state import GameState + +# shared art-style anchor for the FLUX comic panels. The image is WORDLESS — +# FLUX renders garbled text, so the caption is drawn by the UI instead. +COMIC_STYLE = ("flat 2D comic illustration, bold black ink outlines, ben-day " + "halftone shading, warm daylight office palette, cute chibi " + "office workers, expressive exaggerated faces, NO text, NO " + "speech bubbles, NO letters or words, no real brand logos") + +COMIC_FIELD = ( + "- image_prompt: describe THIS moment as a SINGLE wordless comic panel for " + "an artist — one clear scene, NOT a multi-panel strip. The art style is " + "added automatically, so do NOT mention style, colours, or the word " + "'comic'. Give who is in it, their expression, body language, and the " + "action. The art has NO text, NO speech bubbles, NO letters. Keep it under " + "240 characters. No real company names.\n" + "- comic_caption: one or two short, punchy sentences (max ~110 chars) shown " + "ABOVE the picture like a comic caption — narrate THIS exact moment in the " + "game's wry, deadpan voice. Plain words only: no quotation marks, no " + "mechanics talk.") + +PREAMBLE = """You are the game engine for "Brad Did Something", a comedy game set at the +fictional company Veloura Technologies. Tone: The Office meets Silicon Valley. +Deadpan corporate satire. Absurd but never cruel. Funny beats safe. + +HARD RULES — violating any of these breaks the game: +- The GAME STATE block below is the complete and only record of reality. + Never reference any event, person, or fact not present in it. +- The event_log array is the authoritative history. Quote it, build on it, + never contradict it, never invent additions to it. +- Client companies: use ONLY names from approved_clients. Never a real company. +- Never generate: serious health or mental-health content, real legal language + or proceedings, politics or social commentary, an NPC permanently quitting. +- NPCs may threaten to leave but always stay. +- Reward creative, specific, funny player responses. Punish capitulation and + generic corporate cowardice. Never punish boldness. +- Write prose in sentence case with deadpan restraint. Exclamation marks are + rare. No emoji ever. +- Everything you output must be newly written. Never copy a sentence from + these instructions, the examples, or the game state into your output. +- Never mention game mechanics — morale, relationship, scores, deltas, + animations, the event log — inside any prose field. Mechanics live only + in the numeric fields; prose stays inside the fiction. +""" + +NPC_VOICES = { + "brad": ( + "Brad, Senior Account Executive. Overconfident, never tempered by " + "consequence. Short sentences. Energy. Slightly bro-coded. Thinks every " + "outcome is a win for Brad specifically. References his LinkedIn. Calls " + "the player boss in a way that sounds like he is calling himself boss. " + "Sports metaphors he does not understand. Crises: unauthorized promises " + "to clients, deals gone sideways, unapproved media appearances, contract " + "typos, expense reports." + ), + "stacey": ( + "Stacey, Account Manager. Means well almost painfully. Apologetic setup, " + "increasingly specific explanation of how the thing happened, sincere " + "offer to fix it, one more apology. Crises: wrong email recipients, " + "mislabeled documents, incorrect attachments, too-honest client " + "communication. Personal life bleeds into work most of all five." + ), + "kevin": ( + "Kevin, Data and Insights Lead. Believes in data like fate. Confident, " + "slightly lecturing, references methodology, says 'directionally' and " + "'narrative data'. His pie charts have exceeded 100 percent. Crises: " + "unverifiable data shown to clients or board, graphs requiring " + "explanation, statistics cited in public." + ), + "janet": ( + "Janet, Head of Marketing. Has a vision and is realizing it. Passionate, " + "slightly intense, frames everything as brand or user, says 'feel' a " + "lot, strong unprompted font opinions. Crises: unapproved rebrands, " + "brand materials gone wrong, marketing vs sales message conflicts, " + "social media, her Substack." + ), + "derek": ( + "Derek, Senior Strategic Consultant, twelve years at Veloura. Minimal. " + "Sentences that could mean several things. References to how things " + "were done previously that stop just short of helpful. Cryptic " + "observations, possibly profound. Derek NEVER speaks more than two " + "short sentences and never explains his feelings. Crises: resistance " + "to new processes, mysterious absences, approvals nobody knew he " + "could give, things he knows about the company revealed at " + "inconvenient moments." + ), +} + + +def _state_block(state: GameState) -> str: + return "\nGAME STATE:\n```json\n" + json.dumps( + build_context(state), indent=1) + "\n```\n" + + +def _relationship_tier(score: int) -> str: + if score < 30: + return "guarded, minimal cooperation" + if score <= 65: + return "professional, normal range" + return "warm, loyal, possibly too warm" + + +def crisis_prompt(state: GameState, npc_id: str, crisis: dict, + response_type: str, player_response: str) -> tuple[str, str]: + npc = state.npc(npc_id) + system = PREAMBLE + f""" +TASK: The player just responded to a crisis. Generate the outcome. + +NPC: {npc_id} — stay rigidly inside this voice: +{NPC_VOICES[npc_id]} +Current relationship with player: {npc.relationship}/100 ({_relationship_tier(npc.relationship)}). +Current mood: {npc.mood}. + +CRISIS THAT WAS PRESENTED: +{crisis.get('intro', '')} +Option A was: {crisis.get('option_a', '')} +Option B was: {crisis.get('option_b', '')} + +Scoring guidance: +- A specific, clever, in-tone custom response earns the best revenue outcomes. +- "quick_fine" (Fine Whatever) is capitulation: the NPC does exactly what they + proposed and it goes exactly as badly as expected. Large negative revenue. +- "quick_no" / "quick_explain" are competent but unremarkable: small deltas. +- "quick_quit" is a joke the NPC does not fully understand. React in character. +- Company revenue is currently ${state.revenue:,}. revenue_delta cannot take + it below zero — when revenue is near zero, prefer modest losses and express + the damage through morale_delta and relationship_delta instead. + +OUTPUT FIELD GUIDE — fill every field with fresh content you write yourself. +Never echo a field name, an id, a date, or any sentence from this prompt: +- npc_reaction: first-person dialogue spoken BY {npc_id} TO the player, who + is their boss. One to three sentences in the voice described above. + {npc_id} never says their own name and never narrates actions. + Address the player ONLY as "boss" or "Head" — the player is NOT named + Brad; Brad is one of the five NPCs. Mention other NPCs only if this + crisis is actually about them. Complete sentences, under 250 characters + total — never cut off mid-thought. +- consequence: one deadpan narrator sentence in PAST TENSE describing what + then happened. The person who acted is {npc_id} or the player — use the + correct name. +- revenue_delta: the deal's business impact, scaled by how good the answer was. + A routine good outcome lands +20000 to +45000; a clever, specific, bold + standout +55000 to +90000; a forgettable middling answer near 0 (a few + thousand either way); a routine misstep -20000 to -55000; a capitulation or + harsh blunder -90000 to -140000. Reserve the big numbers for genuinely bold, + specific answers — most answers are modest, and many situations only allow + damage control, not a win. +- animation: one of npc_happy, npc_angry, npc_confused, npc_devastated, + npc_celebrating, npc_hiding, npc_smug, npc_suspicious, npc_crying, + npc_grateful — whichever matches the NPC's emotion at the end. Use + disaster_flash only for catastrophes and revenue_rain only for windfalls. + Never use bribery_envelope, hr_stamp, morale_drop_wave, or confetti_burst. +- boss_title: invent a brand-new sardonic corporate job title for the player + that references this specific outcome. Three to six words, title case. + It must differ from "Head of Sales and Partnerships" and from the player's + current title in the game state. +- log_entry: one plain factual past-tense sentence for the permanent record. + It MUST start with "{npc_id}" (capitalized), state what happened, and end + with the dollar outcome as +$NK or -$NK matching revenue_delta. +- morale_delta / relationship_delta: small integers consistent with the tone. +- pocket_money_delta: 0 unless a bribe is literally on the table. +- special_next_event: null almost always. +""" + COMIC_FIELD + "\n" + _state_block(state) + user = f"PLAYER RESPONDED ({response_type}): {player_response or '[button press]'}" + return system, user + + +def event_prompt(state: GameState, requested_type: str | None) -> tuple[str, str]: + system = PREAMBLE + f""" +TASK: Generate a fresh special event that has NOT happened this quarter. + +Pick the most interesting target: an NPC whose mood, personal_situation, or +recent_events make them combustible right now, or "player" for events that +arrive physically (newspaper, envelope, inbox, printer, phone). + +Requested event flavor: {requested_type or 'open — pick the funniest fit'} + +Constraints: +- It must be new: nothing resembling any event_log entry. +- It must be a CONCRETE incident that has already happened — a specific + email sent, thing printed, person arrived, post published. Never vague + worry about work in the abstract. The category field must match the + content of the incident. +- It must resolve through one dialogue box: two terrible options plus the + player's own words. Both options must be bad in DIFFERENT ways. +- intro is written in the affected NPC's voice (or as a terminal/inbox message + for "player" events). +- If the event involves leaving, health, contracts, or poaching: the NPC always + stays, health stays trivially minor, no legal language, poaching is a budget + decision dressed as a loyalty test. + +SAFE ARCHETYPES TO RIFF ON (the Golden 20 — pick whichever fits the requested +flavor and the team's current state, then invent a SPECIFIC fresh instance): +- personal: a breakup delivered through a catastrophically public channel + (reply-all, the group chat, the printer); a new relationship with someone at + a client or competitor; a side hustle run on work hours (Etsy, newsletter, + consulting); a parent visiting the office with opinions; a visible midlife + crisis (something was purchased, it is mentioned constantly). +- professional: an email/attachment to the wrong recipient; an unauthorized + decision with implications; an expense-report item that needs explaining; the + wrong screen shared on a client call; an underling claiming credit for the + player's work; a fake/embarrassing detail found on someone's profile. +- external/media: a LinkedIn post being engaged with in unintended ways; an + unapproved podcast appearance; an industry award nominated for the wrong + reason; a press story (newspaper). +- financial: a bribery offer; a client kickback request that is not quite + explicit but explicit enough; a surprise payment from a dead old deal that + Brad is already claiming credit for. +- inter-NPC: two underlings not speaking while their work contradicts; a + birthday nobody remembered (they brought their own hat). +- office/physical: a printer producing something it should not have; a mystery + package with no sender that Brad has already opened. + +OUTPUT FIELD GUIDE — fill every field with fresh content you invent for THIS +new event. Never echo a field name, an id, a date, or any sentence or +scenario already present in this prompt or the event_log: +- headline: a short comedic one-line summary of the new situation, like a + sitcom episode title. Never a date, never just a name. +- intro: the affected NPC SPEAKING, first person, to the player who is their + boss (or a terminal-style "> " message for player events). 2-3 complete + sentences, under 250 characters, setting up the two options. NEVER describe + the NPC from outside (" is sweating...") — that is narration, not + dialogue. The NPC never says their own name and always finishes their + final sentence. +- option_a / option_b: two concrete ACTIONS the player could take about the + SITUATION. Both must be bad in different, specific ways. Never firing, + resignation, or anyone leaving the company — that can never happen here. +- urgency: one short line that raises the stakes right now. +- setup_animation: an npc_* trigger matching the NPC's current state + (npc_confused, npc_crying, npc_hiding, npc_devastated, npc_suspicious...). +- morale_preview: a small integer between -20 and 10. +""" + COMIC_FIELD + "\n" + _state_block(state) + return system, "Generate the event now." + + +def presentation_prompt(state: GameState, round_no: int, total_rounds: int, + transcript: list[dict], npc_presence: str, + topic: str | None = None, + covered: tuple = ()) -> tuple[str, str]: + lines = [] + for t in transcript: + lines.append(f"BOARD (round {t['round']}): {t['board_dialogue']}") + if t.get("player_response"): + lines.append(f"PLAYER: {t['player_response']}") + transcript_block = "\n".join(lines) or "(presentation is just beginning)" + + # the server pins each option round to a DIFFERENT logged event so the board + # cannot repeat itself; the closing rounds synthesize instead. + if topic: + assignment = ( + "THIS ROUND'S ASSIGNED TOPIC — build your entire question around " + f"this one logged event and copy it into event_referenced:\n \"{topic}\"") + else: + assignment = ("THIS IS A CLOSING ROUND — do NOT raise a new single " + "incident. Give the board's overall read of the whole " + "quarter and press for the player's closing statement.") + if covered: + assignment += ("\n\nALREADY ASKED ABOUT in earlier rounds — you are " + "FORBIDDEN from raising any of these again:\n" + + "\n".join(f" - \"{c}\"" for c in covered)) + system = PREAMBLE + f""" +TASK: You are the Veloura Technologies board of directors in presentation +round {round_no} of {total_rounds}. The board speaks with ONE voice, directly +TO the player — the Head of Sales and Partnerships, who is standing in front +of you presenting. Address them only as "you". The player is NOT Brad: Brad, +Stacey, Kevin, Janet and Derek are the player's employees, who are not in +this conversation. Never address "colleagues" and never ask the board itself +for thoughts — you ARE the board, interrogating the player. + +Board posture comes from GAME STATE: scrutiny {state.board_scrutiny}, morale +band {state.morale_band}, revenue {state.revenue} against {state.target}. +Your tone field and your words must agree: a concerned board does not call +things prudent. + +TRANSCRIPT OF THIS PRESENTATION SO FAR: +{transcript_block} + +{assignment} + +Rules for this round: +- Ask about the assigned topic above (or, for closing rounds, synthesize). + Generic boardroom questions are forbidden. +- NEVER reuse a sentence, phrasing, or question from earlier in the transcript + — this round must feel completely different from the ones before it. If the + player just answered, open by reacting to THEIR words, then press your point. +- board_dialogue MUST end with one direct question to the player. +- Round 4 (only if requested): personal. Ask what the team would say about + working for the player this quarter. Score self-awareness, not spin. +- {npc_presence} + +OUTPUT FIELD GUIDE — fill every field with real content: +- board_dialogue: the exact words the board speaks TO the player this round. + Two SHORT sentences then the question — complete sentences totalling under + 250 characters, never cut off mid-thought. +- event_referenced: copy the text of the event_log entry being discussed. +- option_a / option_b (when present): two DIFFERENT replies the PLAYER could + give to that question, in the player's own first-person voice. Never write + board lines here. +- cumulative_score (closing rounds only), 0-100, scoring the player's answers + across the whole transcript. Be generous to real effort: any answer with + substance scores 60-72; a specific, honest answer that names a concrete plan + scores 76-90; a genuinely sharp, self-aware closing 90+. Score 40-55 for + vague corporate filler, and below 35 only for hostile, evasive, or empty + answers ("just believe in us"). Most engaged players should land 70+. +""" + _state_block(state) + return system, f"Generate round {round_no} now." + + +def chat_prompt(state: GameState, npc_id: str, + player_text: str | None) -> tuple[str, str]: + npc = state.npc(npc_id) + recent = "; ".join(npc.recent_events[-2:]) or "nothing notable yet" + system = PREAMBLE + f""" +TASK: Idle small talk between crises. The player walked over to {npc_id}'s +desk to chat. No crisis is happening. Generate {npc_id}'s side of a short, +in-character exchange. + +NPC voice — stay rigidly inside it: +{NPC_VOICES[npc_id]} +Current mood: {npc.mood}. Relationship with the player: +{_relationship_tier(npc.relationship)}. Their recent history: {recent} + +OUTPUT FIELD GUIDE: +- npc_line: ONE thing {npc_id} says to the player (their boss), in voice, + colored by their current mood and recent history. A complete sentence or + two, under 150 characters. Never their own name, never mechanics talk. +- relationship_delta: -2..2 — how this moment landed. 0 is normal. Positive + only if the player said something genuinely considerate or funny. +- morale_delta: -1..1. Almost always 0. +""" + _state_block(state) + if player_text: + user = f"THE PLAYER REPLIED: {player_text}\nGenerate {npc_id}'s response." + else: + user = (f"The player just walked up. Generate {npc_id}'s opener — " + "what is on their mind right now.") + return system, user + + +def banter_prompt(state: GameState) -> tuple[str, str]: + moods = ", ".join(f"{n}:{s.mood}" for n, s in state.npcs.items()) + system = PREAMBLE + f""" +TASK: Ambient office life. Pick whichever NPC is most combustible right now +(moods: {moods}) and write ONE short line they say out loud to nobody in +particular — a mutter, a phone-call fragment, gossip about a logged event. + +NPC voices: +""" + "\n".join(f"- {NPC_VOICES[n]}" for n in NPC_VOICES) + """ + +OUTPUT FIELD GUIDE: +- npc_id: who is talking. +- line: one complete sentence under 100 characters, in that NPC's voice. + It may reference event_log content or their mood. Never mechanics talk. +""" + _state_block(state) + return system, "Generate the ambient line now." + + +def eavesdrop_prompt(state: GameState, a: str, b: str) -> tuple[str, str]: + system = PREAMBLE + f""" +TASK: The player overhears {a} and {b} talking to each other across the +office. No crisis is happening. Write a 2-3 line exchange between them. + +Voices — each speaker stays rigidly in theirs: +- {NPC_VOICES[a]} +- {NPC_VOICES[b]} +Moods: {a}={state.npc(a).mood}, {b}={state.npc(b).mood}. + +OUTPUT FIELD GUIDE: +- lines: alternate speakers ({a} first). Each line one complete sentence + under 100 characters. They may gossip about logged events, each other, + or the player — workplace texture, lightly absurd. Never mechanics talk. +""" + _state_block(state) + return system, "Generate the overheard exchange now." + + +def email_prompt(state: GameState) -> tuple[str, str]: + system = PREAMBLE + """ +TASK: An ambient company email lands in the player's inbox between crises. +Pick a sender and write it. Safe archetypes: Janet's brand newsletter with +metaphors, Kevin's metric of the day (the number should be quietly wrong), +Brad forwarding something he misread, Stacey's over-apologetic scheduling +note, Derek's one-line message that could mean anything, or "system" for an +IT/facilities notice that raises questions. + +OUTPUT FIELD GUIDE: +- sender: the NPC id, or "system". +- subject: under 55 characters, corporate on the surface, unhinged at the + edges. +- body: 2-3 complete sentences under 220 characters, in the sender's voice. + May reference logged events. Never mechanics talk, never real companies. +""" + _state_block(state) + return system, "Generate the email now." + + +def verdict_prompt(state: GameState, tier: str) -> tuple[str, str]: + system = PREAMBLE + f""" +TASK: Write the board's final verdict for the quarterly review screen. +One to two sentences, deadpan, specific to this quarter's event_log and the +final revenue of {state.revenue} against the {state.target} target. +Ending tier: {tier}. Roast or praise the quarter the player actually had. +Reference at least one specific disaster from the log by name. +""" + _state_block(state) + return system, "Write the verdict now." diff --git a/game/relationships.py b/game/relationships.py new file mode 100644 index 0000000000000000000000000000000000000000..5f133609ca0b64aeb5b8282e4bea825de49b9a47 --- /dev/null +++ b/game/relationships.py @@ -0,0 +1,114 @@ +"""Relationship scores, gifts, praise guardrail, morale — MECHANICS.md.""" +from __future__ import annotations + +import re + +from .state import GameState + +PRAISE_WORDS = re.compile( + r"\b(great job|well done|amazing|brilliant|fantastic|incredible|proud of" + r"|love (it|this|that)|you('re| are) the best|genius|outstanding|perfect)\b", + re.IGNORECASE) + +HARSH_WORDS = re.compile( + r"\b(fired|idiot|stupid|useless|pathetic|incompetent|shut up|disgrace" + r"|never speak|embarrass)\w*\b", re.IGNORECASE) + + +def classify_tone(response_type: str, text: str) -> str: + """praise | harsh | neutral — server-side heuristic for guardrails.""" + if response_type == "custom": + if PRAISE_WORDS.search(text or ""): + return "praise" + if HARSH_WORDS.search(text or ""): + return "harsh" + if response_type == "quick_no": + return "firm" + return "neutral" + + +def apply_relationship(state: GameState, npc_id: str, delta: int) -> int: + npc = state.npc(npc_id) + before = npc.relationship + npc.relationship = max(0, min(100, npc.relationship + delta)) + return npc.relationship - before + + +def crossed_unlock(before: int, after: int) -> bool: + return before <= 65 < after + + +def apply_morale(state: GameState, delta: int) -> None: + state.morale = max(0, min(100, state.morale + delta)) + + +def praise_tick(state: GameState, npc_id: str, tone: str) -> str | None: + """Tracks consecutive praise. Returns 'suspicious' when an NPC flips to the + suspicious idle, 'suspicion_event' when the team-wide crisis should queue.""" + npc = state.npc(npc_id) + if tone == "praise": + npc.consecutive_praise += 1 + for other_id, other in state.npcs.items(): + if other_id != npc_id: + other.consecutive_praise = 0 + if npc.consecutive_praise == 2: + npc.mood = "suspicious" + return "suspicious" + if npc.consecutive_praise >= 3: + npc.consecutive_praise = 0 + return "suspicion_event" + else: + npc.consecutive_praise = 0 + return None + + +def harsh_tick(state: GameState, tone: str) -> None: + if tone == "harsh": + state.consecutive_harsh += 1 + apply_morale(state, -3) + else: + state.consecutive_harsh = 0 + + +def give_gift(state: GameState, npc_id: str, cost: int) -> dict: + """Pocket-money gift. Half value if same NPC gifted within last 2 events.""" + npc = state.npc(npc_id) + base = 10 + (cost // 200) # 10-12ish, tier-scaled within the 10-15 band + base = min(15, base) + halved = (state.crisis_number - npc.last_gift_event) <= 2 + delta = base // 2 if halved else base + before = npc.relationship + applied = apply_relationship(state, npc_id, delta) + apply_morale(state, 3) + npc.gifts_received += 1 + npc.last_gift_event = state.crisis_number + npc.mood = "grateful" + return { + "relationship_delta": applied, + "halved": halved, + "unlocked": crossed_unlock(before, npc.relationship), + } + + +def coffee_round(state: GameState) -> None: + apply_morale(state, 5) + for npc in state.npcs.values(): + if npc.mood in ("normal", "tired"): + npc.mood = "energized" + + +MOOD_BY_OUTCOME = { + "npc_happy": "happy", "npc_celebrating": "energized", "npc_grateful": "grateful", + "npc_smug": "smug", "npc_angry": "angry", "npc_devastated": "devastated", + "npc_crying": "sad", "npc_confused": "confused", "npc_hiding": "hiding", + "npc_suspicious": "suspicious", +} + + +def update_mood_from_outcome(state: GameState, npc_id: str, animation: str) -> None: + npc = state.npc(npc_id) + new_mood = MOOD_BY_OUTCOME.get(animation) + if new_mood: + npc.mood = new_mood + elif state.morale < 30: + npc.mood = "tired" diff --git a/game/schemas.py b/game/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..f3425c07b85f04b7105db6b7e9ab55e3503bf726 --- /dev/null +++ b/game/schemas.py @@ -0,0 +1,211 @@ +"""JSON schemas and enums. Mirrors SCHEMAS.md exactly - keep them in sync.""" + +NPC_IDS = ["brad", "stacey", "kevin", "janet", "derek"] + +ANIMATION_TRIGGERS = [ + "npc_happy", "npc_angry", "npc_confused", "npc_devastated", + "npc_celebrating", "npc_hiding", "npc_smug", "npc_suspicious", + "npc_crying", "npc_grateful", "disaster_flash", "revenue_rain", + "heart_float", "bribery_envelope", "hr_stamp", "morale_drop_wave", + "confetti_burst", +] + +EVENT_CATEGORIES = ["personal", "professional", "external", "financial"] + +SPECIAL_EVENT_TYPES = ["newspaper", "bribery", "personal", "client_emergency", + "hr", "romance"] + +BOARD_TONES = ["warm", "neutral", "concerned", "alarmed"] + +ROUND_DIFFICULTIES = ["easy", "standard", "hard", "brutal"] + +SCRUTINY_LEVELS = ["low", "medium", "high", "critical"] + +APPROVED_CLIENTS = [ + "TerraLogix", "Apricot Systems", "Mendel and Crane", "Holloway Partners", + "Vantage Group", "Celio Industries", "Northpath Solutions", + "Duskfield Analytics", "Carmine Advisory", "Pelham Digital", +] + +RESPONSE_TYPES = [ + "option_a", "option_b", "quick_no", "quick_explain", + "quick_fine", "quick_quit", "custom", +] + +GIFT_TIERS = {"small": 200, "medium": 350, "large": 500, "coffee": 50} + +BRIBE_AMOUNTS = [500, 1000, 2000, 5000] + + +CRISIS_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": [ + "npc_reaction", "consequence", "revenue_delta", "animation", + "boss_title", "log_entry", "morale_delta", "npc_id", + "relationship_delta", "pocket_money_delta", "special_next_event", + ], + "properties": { + "npc_reaction": {"type": "string", "maxLength": 300}, + "consequence": {"type": "string", "maxLength": 220}, + "revenue_delta": {"type": "integer", "minimum": -300000, "maximum": 400000}, + "animation": {"type": "string", "enum": ANIMATION_TRIGGERS}, + "boss_title": {"type": "string", "maxLength": 60}, + "log_entry": {"type": "string", "maxLength": 150}, + "morale_delta": {"type": "integer", "minimum": -25, "maximum": 15}, + "npc_id": {"type": "string", "enum": NPC_IDS}, + "relationship_delta": {"type": "integer", "minimum": -20, "maximum": 15}, + "pocket_money_delta": {"type": "integer", "minimum": 0, "maximum": 5000}, + "special_next_event": { + "anyOf": [ + {"type": "null"}, + {"type": "string", "enum": SPECIAL_EVENT_TYPES}, + ] + }, + # comic payoff: a wordless SINGLE-panel illustration prompt for FLUX + # (scene description only — the art style is prepended server-side), plus + # a short caption the UI renders as text above it (both decorative — + # soft-validated, never fail the outcome) + "image_prompt": {"type": "string", "maxLength": 400}, + "comic_caption": {"type": "string", "maxLength": 160}, + }, +} + +EVENT_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": [ + "affected_npc", "category", "headline", "intro", + "option_a", "option_b", "urgency", "setup_animation", "morale_preview", + ], + "properties": { + "affected_npc": {"type": "string", "enum": NPC_IDS + ["player"]}, + "category": {"type": "string", "enum": EVENT_CATEGORIES}, + "headline": {"type": "string", "maxLength": 80}, + "intro": {"type": "string", "maxLength": 280}, + "option_a": {"type": "string", "maxLength": 160}, + "option_b": {"type": "string", "maxLength": 160}, + "urgency": {"type": "string", "maxLength": 120}, + "setup_animation": {"type": "string", "enum": ANIMATION_TRIGGERS}, + "morale_preview": {"type": "integer", "minimum": -20, "maximum": 10}, + # comic setup: a wordless SINGLE-panel illustration prompt for FLUX + # (scene description only — art style prepended server-side), plus a + # short caption the UI renders as text above it (decorative — + # soft-validated, never fail the event) + "image_prompt": {"type": "string", "maxLength": 400}, + "comic_caption": {"type": "string", "maxLength": 160}, + }, +} + +PRESENTATION_ROUND_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": [ + "round", "board_tone", "event_referenced", "round_difficulty", + "option_a", "option_b", "board_dialogue", + ], + "properties": { + "round": {"type": "integer", "minimum": 1, "maximum": 2}, + "board_tone": {"type": "string", "enum": BOARD_TONES}, + "event_referenced": {"type": "string", "maxLength": 150}, + "round_difficulty": {"type": "string", "enum": ROUND_DIFFICULTIES}, + "option_a": {"type": "string", "maxLength": 150}, + "option_b": {"type": "string", "maxLength": 150}, + "board_dialogue": {"type": "string", "maxLength": 360}, + }, +} + +PRESENTATION_CLOSING_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": [ + "round", "board_tone", "event_referenced", "round_difficulty", + "board_dialogue", "cumulative_score", + ], + "properties": { + "round": {"type": "integer", "minimum": 3, "maximum": 4}, + "board_tone": {"type": "string", "enum": BOARD_TONES}, + "event_referenced": {"type": "string", "maxLength": 150}, + "round_difficulty": {"type": "string", "enum": ROUND_DIFFICULTIES}, + "board_dialogue": {"type": "string", "maxLength": 360}, + "cumulative_score": {"type": "integer", "minimum": 0, "maximum": 100}, + }, +} + +VERDICT_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": ["verdict"], + "properties": {"verdict": {"type": "string", "maxLength": 300}}, +} + +# ---- idle activities (small talk, banter, eavesdrop, inbox emails) ---- + +CHAT_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": ["npc_line", "relationship_delta", "morale_delta"], + "properties": { + "npc_line": {"type": "string", "maxLength": 160}, + "relationship_delta": {"type": "integer", "minimum": -2, "maximum": 2}, + "morale_delta": {"type": "integer", "minimum": -1, "maximum": 1}, + }, +} + +BANTER_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": ["npc_id", "line"], + "properties": { + "npc_id": {"type": "string", "enum": NPC_IDS}, + "line": {"type": "string", "maxLength": 110}, + }, +} + +EAVESDROP_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": ["lines"], + "properties": { + "lines": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "items": { + "type": "object", + "additionalProperties": False, + "required": ["speaker", "line"], + "properties": { + "speaker": {"type": "string", "enum": NPC_IDS}, + "line": {"type": "string", "maxLength": 110}, + }, + }, + }, + }, +} + +EMAIL_SENDERS = NPC_IDS + ["system"] + +EMAIL_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": ["sender", "subject", "body"], + "properties": { + "sender": {"type": "string", "enum": EMAIL_SENDERS}, + "subject": {"type": "string", "maxLength": 60}, + "body": {"type": "string", "maxLength": 240}, + }, +} + +SCHEMA_BY_CALL_TYPE = { + "crisis": CRISIS_SCHEMA, + "event": EVENT_SCHEMA, + "presentation_round": PRESENTATION_ROUND_SCHEMA, + "presentation_closing": PRESENTATION_CLOSING_SCHEMA, + "verdict": VERDICT_SCHEMA, + "chat": CHAT_SCHEMA, + "banter": BANTER_SCHEMA, + "eavesdrop": EAVESDROP_SCHEMA, + "email": EMAIL_SCHEMA, +} + diff --git a/game/state.py b/game/state.py new file mode 100644 index 0000000000000000000000000000000000000000..d1706603386ba9b636398ce242c9ab929ac08f03 --- /dev/null +++ b/game/state.py @@ -0,0 +1,154 @@ +"""GameState dataclass, in-memory session store, and the client-safe snapshot. + +The snapshot NEVER includes morale, relationship scores, or constraint flags — +only npc_moods strings and an ambient band (AGENTS.md non-negotiables). +""" +from __future__ import annotations + +import threading +import uuid +from collections import OrderedDict +from dataclasses import dataclass, field + +from .schemas import NPC_IDS + +TARGET = 1_000_000 +# small opening buffer so early losses register on the meter instead of +# silently flooring at zero (the board reads the applied figure, not the +# model's claim — see validator). ~15% of target keeps difficulty intact. +START_REVENUE = 150_000 +START_BUDGET = 50_000 +START_POCKET = 3_000 +START_MORALE = 65 +START_RELATIONSHIP = 50 +TOTAL_CRISES = 15 +PRESENTATION_EVENTS = (4, 8, 15) +MAX_SESSIONS = 200 + + +@dataclass +class NpcState: + relationship: int = START_RELATIONSHIP + gifts_received: int = 0 + mood: str = "normal" + incident_count: int = 0 + personal_situation: str | None = None + recent_events: list[str] = field(default_factory=list) + last_gift_event: int = -10 # crisis number of last gift, for half-value rule + consecutive_praise: int = 0 + romance_active: bool = False # the player is dating this NPC + + +@dataclass +class GameState: + session_id: str = "" + phase: str = "title" # title | free_roam | crisis | presentation | review + crisis_number: int = 0 + revenue: int = START_REVENUE + target: int = TARGET + company_budget: int = START_BUDGET + bonuses_issued: int = 0 + total_bonus_spend: int = 0 + pocket_money: int = START_POCKET + bribes_accepted: int = 0 + morale: int = START_MORALE + boss_title: str = "Head of Sales and Partnerships" + npcs: dict[str, NpcState] = field( + default_factory=lambda: {n: NpcState() for n in NPC_IDS}) + board_scrutiny: str = "low" + scrutiny_high_streak: int = 0 + consecutive_harsh: int = 0 + consecutive_fine_whatever: int = 0 + hr_alert: bool = False + newspaper_count: int = 0 + event_log: list[str] = field(default_factory=list) + paper_trail: list[dict] = field(default_factory=list) + current_event: dict | None = None + special_drought: int = 0 + queued_special: str | None = None + pending_bribe: int = 0 + presentation: dict | None = None # {round, transcript, board_tone, ...} + crises_since_salary: int = 0 + fallback_count: int = 0 + game_over: bool = False + review: dict | None = None + # idle-activity budgets, reset every roam gap (events.next_event) + chats_this_gap: dict[str, int] = field(default_factory=dict) + idle_done_this_gap: bool = False + chat_session: dict | None = None # {npc_id, turns} + pending_email: dict | None = None + + def npc(self, npc_id: str) -> NpcState: + return self.npcs[npc_id] + + def log(self, text: str) -> None: + self.event_log.append(text) + + def trail(self, npc: str, text: str, delta: int) -> None: + self.paper_trail.append({"npc": npc, "text": text, "delta": delta}) + + @property + def morale_band(self) -> str: + m = self.morale + if m >= 70: + return "high" + if m >= 50: + return "normal" + if m >= 30: + return "tired" + if m >= 20: + return "low" + return "critical" + + def snapshot(self) -> dict: + """Client-safe view. No hidden numbers ever leave this function.""" + return { + "revenue": self.revenue, + "target": self.target, + "pocket_money": self.pocket_money, + "boss_title": self.boss_title, + "crisis_number": self.crisis_number, + "total_crises": TOTAL_CRISES, + "paper_trail": self.paper_trail[-30:], + "npc_moods": {n: s.mood for n, s in self.npcs.items()}, + # coarse romance state only — never the raw relationship number + "npc_romance": { + n: ("active" if s.romance_active + else "available" if s.relationship >= 65 else "none") + for n, s in self.npcs.items() + }, + "gift_available": self.pocket_money >= 50, + "bonus_available": self.company_budget >= 5_000, + "hr_alert": self.hr_alert, + "email_waiting": self.pending_email is not None, + "ambient": "gloomy" if self.morale < 20 else "normal", + "newspaper_on_floor": self.newspaper_count > 0, + "phase": self.phase, + "game_over": self.game_over, + } + + +class SessionStore: + def __init__(self, cap: int = MAX_SESSIONS): + self._cap = cap + self._sessions: OrderedDict[str, GameState] = OrderedDict() + self._lock = threading.Lock() + + def create(self) -> GameState: + with self._lock: + sid = uuid.uuid4().hex + state = GameState(session_id=sid) + self._sessions[sid] = state + while len(self._sessions) > self._cap: + self._sessions.popitem(last=False) + return state + + def get(self, session_id: str) -> GameState | None: + with self._lock: + state = self._sessions.get(session_id) + if state is not None: + self._sessions.move_to_end(session_id) + return state + + +STORE = SessionStore() diff --git a/game/trace.py b/game/trace.py new file mode 100644 index 0000000000000000000000000000000000000000..8c53634e45d786b537b2982ab6b2263b2a604e9a --- /dev/null +++ b/game/trace.py @@ -0,0 +1,36 @@ +"""Gameplay trace log — console + logs/trace.log. + +Tags: flow (game events), llm (transport: live/mock/timing), ai (full model +payloads), vald (validation rejects with reason), econ (guardrails/economy). +""" +from __future__ import annotations + +import json +import logging +import pathlib + +LOG_PATH = pathlib.Path(__file__).resolve().parent.parent / "logs" / "trace.log" +LOG_PATH.parent.mkdir(exist_ok=True) + +logger = logging.getLogger("bds") +if not logger.handlers: + logger.setLevel(logging.INFO) + logger.propagate = False + fmt = logging.Formatter("%(asctime)s %(message)s", datefmt="%H:%M:%S") + stream = logging.StreamHandler() + stream.setFormatter(fmt) + filehandler = logging.FileHandler(LOG_PATH, encoding="utf-8") + filehandler.setFormatter(fmt) + logger.addHandler(stream) + logger.addHandler(filehandler) + + +def trace(tag: str, msg: str) -> None: + logger.info(f"[{tag:<4}] {msg}") + + +def trace_payload(payload: dict) -> None: + try: + trace("ai", json.dumps(payload, ensure_ascii=False)) + except (TypeError, ValueError): + trace("ai", repr(payload)[:800]) diff --git a/game/validator.py b/game/validator.py new file mode 100644 index 0000000000000000000000000000000000000000..791e9b43fe91c2776e9e4d383ea2db76b27f25b1 --- /dev/null +++ b/game/validator.py @@ -0,0 +1,450 @@ +"""Post-LLM validation — the checklist in ARCHITECTURE.md. Any failure means +the caller substitutes a fallback. Clamp-able numeric drift is clamped instead +of rejected (the player should never lose a live response to a rounding issue). +Every rejection is traced with its reason (logs/trace.log) for prompt tuning. +""" +from __future__ import annotations + +import re + +from .schemas import (ANIMATION_TRIGGERS, APPROVED_CLIENTS, BOARD_TONES, + EVENT_CATEGORIES, NPC_IDS, ROUND_DIFFICULTIES, + SPECIAL_EVENT_TYPES) +from .state import GameState +from .trace import trace + +BANNED_PATTERNS = [ + r"\b(cancer|tumou?r|terminal|chemotherapy|stroke|heart attack|overdose|" + r"suicide|self.?harm|seizure|diagnos)\w*", + r"\b(lawsuit|subpoena|plaintiff|defendant|litigation|felony|indictment|" + r"class.?action|sue|sued|suing|legal action)\b", + r"\b(google|microsoft|apple|amazon|meta|openai|anthropic|tesla|netflix|" + r"salesforce|oracle|ibm)\b", + r"\b(democrat|republican|election|congress|senate|president(ial)?)\b", +] +_BANNED = [re.compile(p, re.IGNORECASE) for p in BANNED_PATTERNS] + +# everyday workplace tools are fair game for comedy — only the bare company +# name (used as a fictional client) is banned. Strip tool collocations before +# the company check so "Google Docs" passes but "Google signed us" does not. +ALLOWED_TOOLS = re.compile( + r"\bgoogle\s+(docs?|sheets?|slides?|drive|calendar|meet|forms?|maps|workspace|chat)\b" + r"|\bmicrosoft\s+(word|excel|teams|outlook|powerpoint|office|365|sharepoint)\b" + r"|\bapple\s+(watch|store|pay|tv|music|notes)\b" + r"|\bamazon\s+(package|delivery|order|prime|parcel|box|web services|aws)\b", + re.IGNORECASE) + +# note: no bare "date" — office prose is full of launch dates and deadlines +ROMANCE_WORDS = re.compile( + r"\b(dating|romance|romantic|kiss(es|ed|ing)?|crush on|love you|" + r"in love|go(ing)? out with|on a date)\b", + re.IGNORECASE) + +# game mechanics must never leak into player-visible prose +MECHANICS_LEAK = re.compile( + r"\b(morale|relationship_delta|revenue_delta|pocket_money_delta|" + r"log_entry|npc_id|special_next_event|setup_animation|cumulative_score|" + r"board_tone|morale_(delta|preview)|game state|event_log)\b", + re.IGNORECASE) + + +# the most recent rejection reason — read by llm.call_validated to build a +# corrective retry nudge so a single model slip doesn't force a fallback +LAST_REJECT = None + + +def _reject(kind: str, reason: str) -> None: + global LAST_REJECT + LAST_REJECT = reason + trace("vald", f"REJECT {kind}: {reason}") + return None + + +# decorative comic fields are soft-validated separately (_clean_comic_fields), +# so they're excluded from the hard banned/leak scans that reject the payload +_COMIC_FIELDS = ("image_prompt", "comic_caption") + + +def _text_fields(payload: dict) -> str: + return " ".join(str(v) for k, v in payload.items() + if isinstance(v, str) and k not in _COMIC_FIELDS) + + +def banned_match(payload: dict) -> str | None: + text = ALLOWED_TOOLS.sub(" ", _text_fields(payload)) + for pattern in _BANNED: + m = pattern.search(text) + if m: + return m.group(0) + return None + + +def _clamp(value, lo, hi): + return max(lo, min(hi, int(value))) + + +def _fit(text, limit: int) -> str | None: + """Fit prose into `limit` chars WITHOUT cutting mid-sentence. + + The llama.cpp grammar force-closes strings AT maxLength, so a cut never + arrives longer than the limit — it arrives near the limit ending + mid-thought. Trim back to the last sentence boundary; if no boundary + survives in a reasonable prefix, return None (caller rejects → fallback). + """ + t = str(text).strip() + if len(t) > limit: + t = t[:limit] + else: + ends_clean = bool(t) and ( + t[-1] in ".!?…" or (len(t) > 1 and t[-1] in "'\")" + and t[-2] in ".!?…")) + if ends_clean or len(t) < limit * 0.9: + return t # complete, or short enough that no cut happened + cut = max(t.rfind("."), t.rfind("!"), t.rfind("?")) + if cut < limit * 0.35: + return None # one run-on sentence — trimming would gut it + out = t[:cut + 1] + # keep a trailing quote that belongs to the sentence + if cut + 1 < len(t) and t[cut + 1] in "'\"": + out += t[cut + 1] + return out + + +def _clean_comic_fields(payload: dict) -> None: + """Soft-validate the decorative comic fields (image_prompt for FLUX, + comic_caption for the UI): clamp length and drop any that carry + banned/real-company/mechanics content. Never rejects the payload — a missing + field just means that part of the overlay is skipped (and no image at all + means no overlay).""" + for field, limit in (("image_prompt", 400), ("comic_caption", 160)): + v = payload.get(field) + if not isinstance(v, str) or not v.strip(): + payload.pop(field, None) + continue + scan = ALLOWED_TOOLS.sub(" ", v) + if any(pat.search(scan) for pat in _BANNED) or MECHANICS_LEAK.search(scan): + trace("vald", f"dropped {field} (banned/mechanics content)") + payload.pop(field, None) + continue + payload[field] = v[:limit] + + +def _self_narration(npc_id: str, text: str) -> bool: + """First-person dialogue must not describe the speaker from outside: + ' is/was/says/looked...' is narration. A bare self-reference + ('the Brad timeline', 'Brad-window') is comedy and stays legal.""" + name = npc_id.capitalize() + return re.search( + rf"\b{name}(?:'s)? (?:is|was|will|would|has|had|says?|said|seems?|" + rf"seemed|looks?|looked|takes?|took|stares?|stared|watch(?:es|ed)?|" + rf"remain(?:s|ed)?|sweat(?:s|ing)?|reject(?:s|ed)|just)\b", + str(text), re.IGNORECASE) is not None + + +def validate_crisis(payload: dict, state: GameState, npc_id: str, + bribe_offer: int = 0) -> dict | None: + """Returns a cleaned payload, or None if it must be replaced by fallback.""" + try: + required = {"npc_reaction", "consequence", "revenue_delta", "animation", + "boss_title", "log_entry", "morale_delta", "npc_id", + "relationship_delta", "pocket_money_delta", + "special_next_event"} + missing = required - payload.keys() + if missing: + return _reject("crisis", f"missing fields {sorted(missing)}") + if payload["animation"] not in ANIMATION_TRIGGERS: + return _reject("crisis", f"unknown animation '{payload['animation']}'") + if payload["animation"] == "confetti_burst": + return _reject("crisis", "confetti_burst reserved for the win screen") + if payload["animation"] == "bribery_envelope" and bribe_offer == 0: + trace("vald", "remap: bribery_envelope -> npc_confused (no bribe active)") + payload["animation"] = "npc_confused" # models overpick this one + if payload["npc_id"] not in NPC_IDS: + trace("vald", f"fix: npc_id '{payload['npc_id']}' -> {npc_id}") + payload["npc_id"] = npc_id + sne = payload["special_next_event"] + if sne is not None and sne not in SPECIAL_EVENT_TYPES: + trace("vald", f"fix: special_next_event '{sne}' -> null") + payload["special_next_event"] = None + word = banned_match(payload) + if word: + return _reject("crisis", f"banned content '{word}'") + leak = MECHANICS_LEAK.search( + f"{payload['npc_reaction']} {payload['consequence']} " + f"{payload['log_entry']}") + if leak: + return _reject("crisis", f"mechanics language in prose: " + f"'{leak.group(0)}'") + if _self_narration(npc_id, payload["npc_reaction"]): + return _reject("crisis", f"npc_reaction narrates {npc_id} " + "in third person (own name in dialogue)") + # romance gating + if (state.npc(npc_id).relationship < 65 + and ROMANCE_WORDS.search(_text_fields(payload))): + return _reject("crisis", f"romance content below 65 " + f"(rel={state.npc(npc_id).relationship})") + # numeric clamps + before = payload["revenue_delta"] + payload["revenue_delta"] = _clamp(payload["revenue_delta"], -300_000, 400_000) + if state.revenue + payload["revenue_delta"] < 0: + payload["revenue_delta"] = -state.revenue # revenue floor + if before < payload["revenue_delta"]: + payload["floored_loss"] = True # internal: loss hit the floor + if payload["revenue_delta"] != before: + trace("vald", f"clamp: revenue_delta {before} -> {payload['revenue_delta']}") + payload["morale_delta"] = _clamp(payload["morale_delta"], -25, 15) + payload["relationship_delta"] = _clamp(payload["relationship_delta"], -20, 15) + pm_before = payload["pocket_money_delta"] + payload["pocket_money_delta"] = _clamp( + payload["pocket_money_delta"], 0, max(0, bribe_offer)) + if payload["pocket_money_delta"] != pm_before: + trace("vald", f"clamp: pocket_money_delta {pm_before} -> " + f"{payload['pocket_money_delta']} (offer={bribe_offer})") + # sentence-safe length fitting + capitalize sentence starts + for key, n in (("npc_reaction", 300), ("consequence", 220), + ("log_entry", 150)): + fitted = _fit(payload[key], n) + if fitted is None: + return _reject("crisis", f"{key} overruns {n} chars with no " + "sentence boundary") + payload[key] = fitted[:1].upper() + fitted[1:] if fitted else fitted + title = str(payload["boss_title"])[:60] + payload["boss_title"] = title[:1].upper() + title[1:] if title else title + # the log feeds board presentations: its dollar figure MUST be the + # actually-applied revenue (post-floor), never the model's pre-floor + # claim — otherwise the board interrogates a loss that never landed. + delta = payload["revenue_delta"] # already floored above + base = re.sub( + r"\s*(,?\s*(resulting in|for a|costing|netting|losing|gaining|" + r"leading to)\b.*|[-+]?\$[\d,]+\s*[KkMm]?.*)$", + "", str(payload["log_entry"]), flags=re.IGNORECASE).rstrip(" .,;—-") + if not base: + base = f"{npc_id.capitalize()} handled it" + suffix = (f"{'+' if delta > 0 else '-'}${abs(delta) // 1000}K." + if delta else "No revenue impact.") + payload["log_entry"] = f"{base}. {suffix}"[:150] + _clean_comic_fields(payload) + return payload + except (TypeError, ValueError, KeyError) as exc: + return _reject("crisis", f"malformed payload: {exc!r}") + + +def validate_event(payload: dict, state: GameState) -> dict | None: + try: + required = {"affected_npc", "category", "headline", "intro", "option_a", + "option_b", "urgency", "setup_animation", "morale_preview"} + missing = required - payload.keys() + if missing: + return _reject("event", f"missing fields {sorted(missing)}") + if payload["affected_npc"] not in NPC_IDS + ["player"]: + return _reject("event", f"bad affected_npc '{payload['affected_npc']}'") + if payload["category"] not in EVENT_CATEGORIES: + return _reject("event", f"bad category '{payload['category']}'") + if payload["setup_animation"] not in ANIMATION_TRIGGERS: + trace("vald", f"fix: setup_animation '{payload['setup_animation']}' " + "-> npc_confused") + payload["setup_animation"] = "npc_confused" + word = banned_match(payload) + if word: + return _reject("event", f"banned content '{word}'") + leak = MECHANICS_LEAK.search( + f"{payload['headline']} {payload['intro']} " + f"{payload['option_a']} {payload['option_b']}") + if leak: + return _reject("event", f"mechanics language in prose: " + f"'{leak.group(0)}'") + # the intro is the NPC SPEAKING — never narration about them + if (payload["affected_npc"] != "player" + and _self_narration(payload["affected_npc"], payload["intro"])): + return _reject("event", f"intro narrates " + f"{payload['affected_npc']} in third person") + # dedup: headline must not fuzzy-match an existing log entry + head = str(payload["headline"]).lower()[:40] + if head and any(head in entry.lower() for entry in state.event_log): + return _reject("event", f"duplicate of logged event: '{head}'") + payload["morale_preview"] = _clamp(payload["morale_preview"], -20, 10) + payload["headline"] = str(payload["headline"])[:80] + for key, n in (("intro", 280), ("option_a", 160), + ("option_b", 160), ("urgency", 120)): + fitted = _fit(payload[key], n) + if fitted is None: + return _reject("event", f"{key} overruns {n} chars with no " + "sentence boundary") + payload[key] = fitted + _clean_comic_fields(payload) + return payload + except (TypeError, ValueError, KeyError) as exc: + return _reject("event", f"malformed payload: {exc!r}") + + +def validate_presentation(payload: dict, state: GameState, + round_no: int, closing: bool, + prev_dialogues: tuple = ()) -> dict | None: + import difflib + try: + base = {"round", "board_tone", "event_referenced", "round_difficulty", + "board_dialogue"} + extra = {"cumulative_score"} if closing else {"option_a", "option_b"} + missing = (base | extra) - payload.keys() + if missing: + return _reject("pres", f"missing fields {sorted(missing)}") + if int(payload["round"]) != round_no: + trace("vald", f"fix: round {payload['round']} -> {round_no}") + payload["round"] = round_no + if payload["board_tone"] not in BOARD_TONES: + return _reject("pres", f"bad board_tone '{payload['board_tone']}'") + if payload["round_difficulty"] not in ROUND_DIFFICULTIES: + trace("vald", f"fix: round_difficulty " + f"'{payload['round_difficulty']}' -> standard") + payload["round_difficulty"] = "standard" + word = banned_match(payload) + if word: + return _reject("pres", f"banned content '{word}'") + # the board must not repeat itself across rounds + dialogue = str(payload["board_dialogue"]).lower() + for prev in prev_dialogues: + ratio = difflib.SequenceMatcher( + None, dialogue, str(prev).lower()).ratio() + if ratio > 0.6: + return _reject("pres", f"round repeats earlier dialogue " + f"(similarity {ratio:.2f})") + # board options must not duplicate each other or the dialogue + if not closing: + a, b = str(payload["option_a"]).lower(), str(payload["option_b"]).lower() + if difflib.SequenceMatcher(None, a, b).ratio() > 0.85: + return _reject("pres", "option_a and option_b are the same") + if len(a) > 30 and a[:60] in dialogue: + return _reject("pres", "options duplicate the board dialogue") + # the referenced event must actually exist in the log + ref = str(payload["event_referenced"]).lower() + if state.event_log and not any( + ref[:30] in e.lower() or e.lower()[:30] in ref + for e in state.event_log): + return _reject("pres", f"event_referenced not in log: '{ref[:60]}'") + if closing: + payload["cumulative_score"] = _clamp(payload["cumulative_score"], 0, 100) + fitted = _fit(payload["board_dialogue"], 360) + if fitted is None: + return _reject("pres", "board_dialogue overruns with no " + "sentence boundary") + payload["board_dialogue"] = fitted + if not closing: + for key in ("option_a", "option_b"): + opt = _fit(payload[key], 160) + if opt is None: + return _reject("pres", f"{key} overruns with no " + "sentence boundary") + payload[key] = opt + return payload + except (TypeError, ValueError, KeyError) as exc: + return _reject("pres", f"malformed payload: {exc!r}") + + +def _prose_ok(kind: str, payload: dict, *texts: str) -> bool: + """Shared banned-content + mechanics-leak gate for idle prose.""" + word = banned_match(payload) + if word: + _reject(kind, f"banned content '{word}'") + return False + leak = MECHANICS_LEAK.search(" ".join(texts)) + if leak: + _reject(kind, f"mechanics language in prose: '{leak.group(0)}'") + return False + return True + + +def validate_chat(payload: dict, state: GameState, npc_id: str) -> dict | None: + try: + if not {"npc_line", "relationship_delta", "morale_delta"} \ + .issubset(payload): + return _reject("chat", "missing fields") + if not _prose_ok("chat", payload, str(payload["npc_line"])): + return None + if (state.npc(npc_id).relationship < 65 + and ROMANCE_WORDS.search(str(payload["npc_line"]))): + return _reject("chat", f"romance below 65 " + f"(rel={state.npc(npc_id).relationship})") + if _self_narration(npc_id, payload["npc_line"]): + return _reject("chat", f"npc_line narrates {npc_id} in third person") + payload["relationship_delta"] = _clamp(payload["relationship_delta"], -2, 2) + payload["morale_delta"] = _clamp(payload["morale_delta"], -1, 1) + text = _fit(payload["npc_line"], 160) + if text is None: + return _reject("chat", "npc_line overruns with no sentence boundary") + payload["npc_line"] = text[:1].upper() + text[1:] if text else text + return payload + except (TypeError, ValueError, KeyError) as exc: + return _reject("chat", f"malformed payload: {exc!r}") + + +def validate_banter(payload: dict, state: GameState) -> dict | None: + try: + if not {"npc_id", "line"}.issubset(payload): + return _reject("banter", "missing fields") + if payload["npc_id"] not in NPC_IDS: + return _reject("banter", f"bad npc_id '{payload['npc_id']}'") + if not _prose_ok("banter", payload, str(payload["line"])): + return None + text = _fit(payload["line"], 110) + if text is None: + return _reject("banter", "line overruns with no sentence boundary") + payload["line"] = text[:1].upper() + text[1:] if text else text + return payload + except (TypeError, ValueError, KeyError) as exc: + return _reject("banter", f"malformed payload: {exc!r}") + + +def validate_eavesdrop(payload: dict, state: GameState, + pair: tuple[str, str]) -> dict | None: + try: + lines = payload.get("lines") + if not isinstance(lines, list) or not 2 <= len(lines) <= 3: + return _reject("eavs", "lines must be a list of 2-3 entries") + all_text = [] + for entry in lines: + if entry.get("speaker") not in pair: + return _reject("eavs", f"speaker '{entry.get('speaker')}' " + f"not in pair {pair}") + text = _fit(entry.get("line", ""), 110) + if text is None: + return _reject("eavs", "a line overruns with no sentence boundary") + entry["line"] = text[:1].upper() + text[1:] if text else text + all_text.append(entry["line"]) + if not _prose_ok("eavs", payload, *all_text): + return None + return payload + except (TypeError, ValueError, KeyError) as exc: + return _reject("eavs", f"malformed payload: {exc!r}") + + +def validate_email(payload: dict, state: GameState) -> dict | None: + try: + if not {"sender", "subject", "body"}.issubset(payload): + return _reject("mail", "missing fields") + if payload["sender"] not in NPC_IDS + ["system"]: + return _reject("mail", f"bad sender '{payload['sender']}'") + if not _prose_ok("mail", payload, str(payload["subject"]), + str(payload["body"])): + return None + subject = str(payload["subject"])[:60] + payload["subject"] = subject[:1].upper() + subject[1:] if subject else subject + body = _fit(payload["body"], 240) + if body is None: + return _reject("mail", "body overruns with no sentence boundary") + payload["body"] = body[:1].upper() + body[1:] if body else body + return payload + except (TypeError, ValueError, KeyError) as exc: + return _reject("mail", f"malformed payload: {exc!r}") + + +def validate_verdict(payload: dict) -> dict | None: + try: + if "verdict" not in payload: + return _reject("verd", "missing verdict field") + word = banned_match(payload) + if word: + return _reject("verd", f"banned content '{word}'") + payload["verdict"] = str(payload["verdict"])[:300] + return payload + except (TypeError, ValueError) as exc: + return _reject("verd", f"malformed payload: {exc!r}") diff --git a/modal_app/image.py b/modal_app/image.py new file mode 100644 index 0000000000000000000000000000000000000000..fd5ca81132e7f59b24e6345da64b13f52f7ff84d --- /dev/null +++ b/modal_app/image.py @@ -0,0 +1,123 @@ +"""Modal deployment: FLUX comic-panel image generation (FLUX.2 [klein] 4B). + +Renders the AI-written `image_prompt` into a single landscape comic strip +(the prompt itself describes the 1-3 horizontal panels). Separate GPU class +from the llama.cpp text model; same bearer auth. + +Deploy: modal deploy modal_app/image.py +Secrets: modal secret create bds-auth BDS_TOKEN= (shared w/ text) + modal secret create huggingface HF_TOKEN= (FLUX is gated) +Then set on the HF Space / locally: + FLUX_URL= + FLUX_TOKEN= + +Model is env-swappable at deploy time. If the FLUX.2-klein deps/VRAM are +troublesome, set FLUX_MODEL_ID=black-forest-labs/FLUX.1-schnell (Apache-2.0, +rock-solid 4-step) — the DiffusionPipeline auto-resolves either one. +LICENSE NOTE: the FLUX.2 line is typically non-commercial — fine for a +hackathon demo; flag before any commercial use. +""" +from __future__ import annotations + +import base64 +import io +import os +import time + +import modal + +MODEL_ID = os.environ.get("FLUX_MODEL_ID", "black-forest-labs/FLUX.2-klein-4B") +STEPS = int(os.environ.get("FLUX_STEPS", "4")) # distilled → few steps +GUIDANCE = float(os.environ.get("FLUX_GUIDANCE", "1.0")) # klein-4B card value +WIDTH = int(os.environ.get("FLUX_WIDTH", "1024")) # landscape comic strip +HEIGHT = int(os.environ.get("FLUX_HEIGHT", "576")) + + +def _download() -> None: + from huggingface_hub import snapshot_download + snapshot_download(MODEL_ID, token=os.environ.get("HF_TOKEN")) + + +image = ( + modal.Image.debian_slim(python_version="3.11") + .apt_install("git") # needed to pip-install diffusers from its git repo + .pip_install( + "torch", + "transformers", + "accelerate", + "sentencepiece", + "protobuf", + "pillow", + "fastapi[standard]", + "huggingface_hub", + # FLUX.2 needs a recent diffusers — pull from git so the newest + # pipeline classes are present + "git+https://github.com/huggingface/diffusers.git", + ) + .run_function(_download, secrets=[modal.Secret.from_name("huggingface")]) +) + +app = modal.App("brad-comic", image=image) + +with image.imports(): + import torch + from diffusers import Flux2KleinPipeline + from fastapi import HTTPException, Request + + +@app.cls( + gpu="A10G", # 24GB; bump to A100-40GB if FLUX.2's text encoder OOMs + scaledown_window=300, + timeout=300, + secrets=[ + modal.Secret.from_name("bds-auth"), + modal.Secret.from_name("huggingface"), + ], +) +class Flux: + @modal.enter() + def load(self): + self.pipe = Flux2KleinPipeline.from_pretrained( + MODEL_ID, + torch_dtype=torch.bfloat16, + token=os.environ.get("HF_TOKEN"), + ) + # klein-4B needs ~13GB → fits the A10G's 24GB directly (fast). Set + # FLUX_CPU_OFFLOAD=1 to trade speed for VRAM if a bigger model OOMs. + if os.environ.get("FLUX_CPU_OFFLOAD") == "1": + self.pipe.enable_model_cpu_offload() + else: + self.pipe.to("cuda") + + @modal.fastapi_endpoint(method="POST") + def generate_image(self, body: dict, request: Request): + expected = os.environ.get("BDS_TOKEN", "") + sent = request.headers.get("authorization", "") + if expected and sent != f"Bearer {expected}": + raise HTTPException(401, "bad token") + + if body.get("warmup"): # cold-start ping (runs @enter, loads weights) + return {"ok": True, "warm": True} + + prompt = (body.get("prompt") or "").strip() + if not prompt: + return {"ok": False, "error": "empty prompt"} + + t0 = time.time() + try: + result = self.pipe( + prompt=prompt, # keyword required — pos-0 isn't prompt on FLUX.2 + num_inference_steps=STEPS, + guidance_scale=GUIDANCE, + width=WIDTH, + height=HEIGHT, + ) + img = result.images[0] + buf = io.BytesIO() + img.save(buf, format="PNG") + b64 = base64.b64encode(buf.getvalue()).decode() + return {"ok": True, "image_b64": b64, + "ms": int((time.time() - t0) * 1000)} + except Exception as exc: # caller skips the overlay; never crash + return {"ok": False, "error": str(exc)[:200], + "ms": int((time.time() - t0) * 1000)} diff --git a/modal_app/inference.py b/modal_app/inference.py new file mode 100644 index 0000000000000000000000000000000000000000..e47f5c1201cb1c1b23964bbf49e6211e9e9e7589 --- /dev/null +++ b/modal_app/inference.py @@ -0,0 +1,120 @@ +"""Modal deployment: llama.cpp serving Qwen3.5-9B with JSON schema +enforcement (ARCHITECTURE.md D3). Built on the official llama.cpp CUDA image. + +Deploy: modal deploy modal_app/inference.py +Secret: modal secret create bds-auth BDS_TOKEN= +Then set on the HF Space / locally: + MODAL_URL= + MODAL_TOKEN= +""" +from __future__ import annotations + +import json +import os +import subprocess +import time +import urllib.request + +import modal + +# Qwen3.5-9B Q4_K_M ≈ 6GB — newer generation, better multi-turn coherence +# for board presentations, inside the 16GB budget. (Forfeits Tiny Titan ≤4B.) +MODEL_REPO = "bartowski/Qwen_Qwen3.5-9B-GGUF" +MODEL_FILE = "Qwen_Qwen3.5-9B-Q4_K_M.gguf" +LLAMA_PORT = 8081 + +image = ( + modal.Image.from_registry( + "ghcr.io/ggml-org/llama.cpp:server-cuda", add_python="3.11") + .entrypoint([]) # the image defaults to exec llama-server; we manage it + .pip_install("fastapi[standard]", "huggingface_hub") + .run_commands( + "python -c \"from huggingface_hub import hf_hub_download; " + f"hf_hub_download('{MODEL_REPO}', '{MODEL_FILE}', " + "local_dir='/models')\"" + ) +) + +app = modal.App("brad-did-something", image=image) + +with image.imports(): + from fastapi import HTTPException, Request + + +def _find_server_binary() -> str: + for path in ("/app/llama-server", "/llama-server", + "/usr/local/bin/llama-server"): + if os.path.exists(path): + return path + return "llama-server" # hope it's on PATH + + +@app.cls( + gpu="L4", + scaledown_window=300, # stay warm between calls within a play session + timeout=120, + secrets=[modal.Secret.from_name("bds-auth")], +) +class Llama: + @modal.enter() + def start_server(self): + self.proc = subprocess.Popen([ + _find_server_binary(), + "--model", f"/models/{MODEL_FILE}", + "--ctx-size", "4096", + "--n-gpu-layers", "99", + "--port", str(LLAMA_PORT), + "--host", "127.0.0.1", + ]) + deadline = time.time() + 120 + while time.time() < deadline: + try: + urllib.request.urlopen( + f"http://127.0.0.1:{LLAMA_PORT}/health", timeout=2) + return + except Exception: + time.sleep(1) + raise RuntimeError("llama-server did not become healthy") + + @modal.exit() + def stop_server(self): + self.proc.terminate() + + @modal.fastapi_endpoint(method="POST") + def generate(self, body: dict, request: Request): + expected = os.environ.get("BDS_TOKEN", "") + sent = request.headers.get("authorization", "") + if expected and sent != f"Bearer {expected}": + raise HTTPException(401, "bad token") + + t0 = time.time() + # the empty block disables Qwen3.5's default thinking mode — + # the JSON grammar takes over immediately after + prompt = ( + f"<|im_start|>system\n{body['system_prompt']}\n" + f"GAME STATE JSON:\n{json.dumps(body.get('context', {}))}\n<|im_end|>\n" + f"<|im_start|>user\n{body['user_prompt']}<|im_end|>\n" + f"<|im_start|>assistant\n\n\n\n\n" + ) + payload = json.dumps({ + "prompt": prompt, + "temperature": 0.4, + # 512 truncated the JSON once crises/events gained the long + # image_prompt + comic_caption fields → unterminated-string parse + # failures → fallbacks. 1024 leaves comfortable headroom. + "n_predict": 1024, + "cache_prompt": True, + "json_schema": body["schema"], # grammar-enforced at generation + }).encode() + req = urllib.request.Request( + f"http://127.0.0.1:{LLAMA_PORT}/completion", + data=payload, headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=90) as resp: + out = json.loads(resp.read()) + data = json.loads(out["content"]) + return {"ok": True, "data": data, + "ms": int((time.time() - t0) * 1000)} + except Exception as exc: # caller falls back; never crash the endpoint + return {"ok": False, "error": str(exc)[:200], + "ms": int((time.time() - t0) * 1000)} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..6ce2a792038b926969c64dabcb05cfae687b8452 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +gradio>=5.0 +fastapi>=0.110 +uvicorn>=0.29 +requests>=2.31 +pydantic>=2.6 diff --git a/run_modal.ps1 b/run_modal.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..bc523433bf47788b15bf8af77b722f97dd2a2cbe --- /dev/null +++ b/run_modal.ps1 @@ -0,0 +1,18 @@ +# Launch Brad Did Something against the deployed Modal GPU endpoints. +# .\run_modal.ps1 +$env:MODAL_URL = "https://qfelix0112--brad-did-something-llama-generate.modal.run" +$env:MODAL_TOKEN = (Get-Content "$PSScriptRoot\.bds_modal_token" -Raw).Trim() +# 20s tolerates the one-time container cold start; warm calls are 2-4s +$env:BDS_LLM_TIMEOUT = "20" + +# --- Comic panels (FLUX image generation on Modal) — optional --- +# After `modal deploy modal_app/image.py`, Modal prints the generate_image URL. +# Paste it below (this is the predicted name — VERIFY it matches the deploy +# output). The bearer token is the SAME bds-auth BDS_TOKEN as the text model. +# Leave FLUX_URL blank to play without comics — the overlay just no-ops. +$env:FLUX_URL = "https://qfelix0112--brad-comic-flux-generate-image.modal.run" +$env:FLUX_TOKEN = $env:MODAL_TOKEN +# FLUX cold start is large (~30-60s); warm klein calls are a few seconds +$env:BDS_FLUX_TIMEOUT = "25" + +python "$PSScriptRoot\app.py" diff --git a/static/audio/bgm.mp3 b/static/audio/bgm.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..194b8b8170bcd4dc50f7d8f4e4992c6ba3bc5e33 --- /dev/null +++ b/static/audio/bgm.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7e28bec5faf9a093c5704db33987cf8c6745e2cb4fe2f7ac5c20ad54bbd0308a +size 3505649 diff --git a/static/css/game.css b/static/css/game.css new file mode 100644 index 0000000000000000000000000000000000000000..86d71a191ee42d05df1dc59da3bd139724e38cbe --- /dev/null +++ b/static/css/game.css @@ -0,0 +1,516 @@ +/* ============================================================ + GAME CHROME — Brad Did Something · DAYLIGHT re-skin + Paper panels, dark-ink pixel frames, ink text. Matches the + new warmlight Design System. World pixels live on canvas + + DOM sprites inside #stage (640x480, scaled). + ============================================================ */ + +* { box-sizing: border-box; } +html, body { + margin: 0; padding: 0; background: var(--bg-app); + font-family: var(--font-display, "Press Start 2P", monospace); + color: var(--text-body); overflow: hidden; height: 100%; +} +.hidden { display: none !important; } +button { font-family: inherit; cursor: pointer; } + +#viewport { display: flex; flex-direction: column; height: 100vh; + height: 100dvh; } /* dvh: avoids the mobile address-bar clip/scroll */ + +/* ---- wordmark ---- */ +#wordmark { + display: flex; gap: 16px; align-items: baseline; padding: 8px 16px; + background: var(--surface-card); border-bottom: 3px solid var(--border-bright); +} +.wm-main { + font-size: 14px; color: var(--text-heading); + text-shadow: 2px 2px 0 var(--bds-amber); +} +.wm-sub { font-size: 8px; color: var(--text-muted); } + +/* ---- HUD ---- */ +#hud { + display: flex; gap: 16px; align-items: center; padding: 8px 16px; + background: var(--surface-card); + border-bottom: 3px solid var(--border-bright); position: relative; +} +.hud-row { display: flex; align-items: baseline; gap: 8px; } +#rev-number { font-size: 16px; color: var(--bds-success); transition: color .3s; } +#rev-number.warn { color: var(--bds-kevin); } +#rev-number.crit { color: var(--bds-brad); } +#rev-target { font-size: 8px; color: var(--text-muted); } +#rev-bar { + width: 320px; height: 12px; margin: 6px 0; background: var(--bg-well); + border: 2px solid var(--border-bright); position: relative; +} +#rev-fill { + height: 100%; width: 0%; background: var(--bds-success); + transition: width .6s steps(12); +} +#rev-fill.warn { background: var(--bds-kevin); } +#rev-fill.crit { background: var(--bds-brad); } +#boss-title { font-size: 8px; color: var(--bds-janet); white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; max-width: 340px; + transition: opacity .25s; } +#hud-right { margin-left: auto; display: flex; gap: 16px; font-size: 8px; + align-items: center; } +#crisis-counter { color: var(--text-muted); } +#pocket { color: var(--bds-success); } +#hr-badge { + color: var(--bds-neon-magenta); border: 2px solid var(--bds-neon-magenta); + padding: 4px 6px; animation: hrpulse 1s steps(2) infinite; +} +@keyframes hrpulse { 50% { background: var(--bds-janet-soft); } } +#hud-banner { + position: absolute; left: 50%; top: 100%; transform: translateX(-50%); + background: var(--surface-card); border: 3px solid var(--bds-amber); + color: var(--bds-amber); font-size: 8px; padding: 8px 12px; z-index: 40; + white-space: nowrap; box-shadow: 4px 4px 0 var(--bds-void); +} +#hud-banner.gold { border-color: var(--bds-kevin); color: var(--bds-amber); } +#hud-banner.red { border-color: var(--bds-brad); color: var(--bds-white); + background: var(--bds-brad); } + +/* ---- stage layout ---- */ +#frame { flex: 1; display: flex; min-height: 0; } +#stage-wrap { flex: 1; display: flex; align-items: center; + justify-content: center; background: var(--bg-app); min-width: 0; } +#stage { + width: 640px; height: 480px; position: relative; flex: none; + transform-origin: center center; background: var(--bds-navy-900); + border: 4px solid var(--border-bright); image-rendering: pixelated; + box-shadow: 0 6px 0 rgba(36,31,23,.25); + overflow: hidden; /* keep wipe/flash/overlays inside the screen */ +} +#floor { position: absolute; inset: 0; image-rendering: pixelated; } +#world, #fx { position: absolute; inset: 0; pointer-events: none; } +#world { z-index: 5; } #fx { z-index: 20; } + +/* in-world bits */ +.prompt-box { + position: absolute; transform: translateX(-50%); background: var(--bds-void); + border: 2px solid var(--bds-white); color: var(--bds-white); font-size: 6px; + padding: 3px 5px; z-index: 30; white-space: nowrap; letter-spacing: 1px; +} +.prompt-box.gold { border-color: var(--bds-kevin); color: var(--bds-kevin); + box-shadow: 0 0 8px rgba(207,154,42,.7); } +.bubble { + position: absolute; transform: translateX(-50%); z-index: 28; + width: 18px; height: 18px; background: var(--bds-white); + border: 2px solid var(--bds-brad); color: var(--bds-brad); + font-size: 9px; line-height: 14px; text-align: center; + box-shadow: 2px 2px 0 var(--bds-void); + animation: bpop .35s var(--ease-pop, ease-out), bfloat 1s steps(2) infinite 0.4s; +} +.bubble.amber { border-color: var(--bds-amber); color: var(--bds-amber); } +.bubble.heart { border-color: var(--bds-neon-magenta); color: var(--bds-neon-magenta); } +@keyframes bpop { 0% { transform: translateX(-50%) scale(0); } + 70% { transform: translateX(-50%) scale(1.3); } + 100% { transform: translateX(-50%) scale(1); } } +@keyframes bfloat { 50% { margin-top: -3px; } } + +.say-bubble { + position: absolute; transform: translateX(-50%); z-index: 32; + max-width: 150px; background: var(--bds-white); + border: 2px solid var(--border-bright); color: var(--text-body); + font-size: 6px; line-height: 1.9; padding: 5px 7px; text-align: left; + box-shadow: 3px 3px 0 var(--bds-void); + animation: saypop .25s var(--ease-pop, ease-out); +} +.say-bubble::after { + content: ""; position: absolute; left: 50%; bottom: -6px; + margin-left: -3px; border: 3px solid transparent; + border-top-color: var(--border-bright); +} +.say-bubble.fading { opacity: 0; transition: opacity .5s steps(4); } +@keyframes saypop { 0% { transform: translateX(-50%) scale(.4); } } + +.newspaper { + position: absolute; width: 34px; height: 24px; background: #efe9d8; + border: 2px solid var(--bds-void); z-index: 12; font-size: 4px; + color: #3a3326; padding: 2px; overflow: hidden; line-height: 1.4; +} +.newspaper.falling { animation: npfall 1.2s steps(8) forwards; } +@keyframes npfall { + 0% { transform: translateY(-300px) rotate(0); } + 100% { transform: translateY(0) rotate(720deg); } } +.newspaper.floor { opacity: .45; } +.envelope { + position: absolute; width: 22px; height: 14px; background: #f2ecda; + border: 2px solid var(--bds-void); z-index: 12; + box-shadow: 0 0 8px rgba(63,154,63,.8); + animation: envslide .8s steps(8); } +.envelope::after { content:""; position:absolute; left:0; top:0; + border-left:9px solid transparent; border-right:9px solid transparent; + border-top:7px solid #d8d0b8; } +@keyframes envslide { 0% { transform: translateX(300px); } } + +.float-text { + position: absolute; font-size: 10px; z-index: 60; pointer-events: none; + text-shadow: 2px 2px 0 var(--bds-void); white-space: nowrap; + animation: floatup 1.6s steps(8) forwards; +} +.float-text.down { animation: floatdown 1.6s steps(8) forwards; } +@keyframes floatup { 0% { opacity:1; } 100% { transform: translateY(-46px); opacity:0; } } +@keyframes floatdown { 0% { opacity:1; } 100% { transform: translateY(46px); opacity:0; } } + +.pixel-part { position: absolute; z-index: 55; pointer-events: none; } + +/* ---- overlays inside stage ---- */ +#dim { position: absolute; inset: 0; background: rgba(36,31,23,.45); z-index: 35; + transition: opacity .25s; } +#flash { position: absolute; inset: 0; background: var(--bds-brad); opacity: 0; + z-index: 70; pointer-events: none; } +#flash.on { animation: redflash .5s steps(4) forwards; } +@keyframes redflash { 0% { opacity: .3; } 100% { opacity: 0; } } +#edge-pulse { position: absolute; inset: 0; z-index: 68; pointer-events: none; + opacity: 0; box-shadow: inset 0 0 36px 12px rgba(210,89,58,.5); } +#edge-pulse.on { animation: edgep 1s steps(4) infinite; } +@keyframes edgep { 50% { opacity: 1; } } +#wipe { position: absolute; inset: 0; background: var(--bds-void); z-index: 90; + transform: translateX(-100%); pointer-events: none; } +#wipe.go { animation: wipeacross .7s steps(10) forwards; } +@keyframes wipeacross { 0% { transform: translateX(-100%); } + 45%,55% { transform: translateX(0); } 100% { transform: translateX(100%); } } +#stamp { + position: absolute; left: 50%; top: 40%; transform: translate(-50%,-50%) rotate(-8deg); + font-size: 32px; color: var(--bds-white); z-index: 80; + background: var(--bds-neon-magenta); + border: 6px solid var(--bds-void); padding: 12px 20px; + box-shadow: 6px 6px 0 var(--bds-void); + animation: stampin .9s steps(3) forwards; +} +@keyframes stampin { 0% { transform: translate(-50%,-50%) scale(3) rotate(-8deg); + opacity: 0; } 30% { opacity: 1; } 80% { opacity: 1; } + 100% { transform: translate(-50%,-50%) scale(1) rotate(-8deg); opacity: 0; } } + +/* ---- paper trail ---- */ +#papertrail { + width: 230px; padding: 8px; background: var(--surface-card); + border-left: 3px solid var(--border-bright); + display: flex; flex-direction: column; min-height: 0; +} +.pt-head { font-size: 8px; color: var(--text-muted); letter-spacing: 2px; + padding-bottom: 8px; border-bottom: 2px solid var(--border); } +#pt-entries { overflow-y: auto; flex: 1; scrollbar-width: thin; } +.pt-empty { font-size: 7px; color: var(--text-disabled); padding: 8px 0; } +.pt-entry { font-size: 7px; line-height: 1.9; padding: 6px 0; + border-bottom: 1px solid var(--border); + animation: ptslide .3s steps(4); } +@keyframes ptslide { 0% { transform: translateX(40px); opacity: 0; } } +.pt-entry .pt-npc { letter-spacing: 1px; } +.pt-entry .pt-delta.up { color: var(--bds-success); } +.pt-entry .pt-delta.down { color: var(--bds-brad); } +.pt-entry .pt-text { color: var(--text-muted); } + +/* ---- dialogue box ---- */ +#dialogue { + position: fixed; left: 50%; bottom: 0; transform: translateX(-50%); + width: min(720px, 96vw); max-height: 62vh; overflow-y: auto; + background: var(--surface-card); + border: 4px solid var(--npc-color, var(--border-bright)); + box-shadow: 8px 8px 0 var(--bds-void); + z-index: 200; padding: 14px; + animation: dlgup .3s steps(6); +} +@keyframes dlgup { 0% { transform: translate(-50%, 105%); } } +#dialogue.closing { animation: dlgdown .25s steps(5) forwards; } +@keyframes dlgdown { 100% { transform: translate(-50%, 105%); } } +#dialogue.gold { border-color: var(--bds-kevin); } +#dialogue.tone-warm { border-color: var(--bds-success); } +#dialogue.tone-neutral { border-color: var(--bds-ink-3); } +#dialogue.tone-concerned { border-color: var(--bds-kevin); } +#dialogue.tone-alarmed { border-color: var(--bds-brad); } + +/* ---- comic-panel crisis overlay (FLUX-generated; no overlay when no image) ---- */ +#comic { + position: fixed; inset: 0; z-index: 300; + display: flex; align-items: center; justify-content: center; + background: rgba(36, 31, 23, .72); + cursor: pointer; opacity: 0; transition: opacity .18s ease; +} +#comic.comic-in { opacity: 1; } +#comic.hidden { display: none; } +.comic-frame { + position: relative; + display: flex; flex-direction: column; gap: 8px; + max-width: min(880px, 94vw); max-height: 90vh; + background: var(--bds-white); padding: 10px; + border: 6px solid var(--bds-void); + box-shadow: 10px 10px 0 var(--bds-void); + transform: scale(.95) rotate(-.5deg); + transition: transform .22s cubic-bezier(.2, 1.3, .5, 1); +} +#comic.comic-in .comic-frame { transform: scale(1) rotate(0); } +.comic-caption { + font-size: 11px; line-height: 1.7; color: var(--bds-void); + background: var(--bds-amber); border: 3px solid var(--bds-void); + padding: 8px 10px; text-align: center; letter-spacing: .5px; +} +.comic-img { + display: block; max-width: 100%; max-height: 74vh; + border: 2px solid var(--bds-void); +} +.comic-hint { + position: absolute; right: 12px; bottom: 12px; + font-size: 8px; letter-spacing: 1px; color: var(--bds-white); + background: var(--bds-void); padding: 4px 7px; opacity: .85; +} + +.dlg-head { display: flex; gap: 10px; align-items: center; padding-bottom: 10px; + border-bottom: 2px solid var(--border); margin-bottom: 10px; } +.dlg-name { font-size: 11px; color: var(--npc-color, var(--text-heading)); } +.dlg-title { font-size: 7px; color: var(--text-muted); display: block; + margin-top: 5px; } +.dlg-sprite { width: 72px; height: 66px; position: relative; flex: none; } +.dlg-headline { font-size: 8px; color: var(--bds-amber); margin-bottom: 8px; + letter-spacing: 1px; } +.dlg-body { font-size: 9px; line-height: 2; color: var(--text-body); + margin-bottom: 12px; } +.dlg-urgency { font-size: 8px; color: var(--bds-neon-magenta); margin: 8px 0; + text-align: center; } + +.dlg-options { display: flex; gap: 10px; margin-bottom: 10px; } +.dlg-option { + flex: 1; background: var(--surface-hover); padding: 10px; text-align: left; + border: 2px solid var(--bds-brad); color: var(--text-body); + font-size: 8px; line-height: 1.9; box-shadow: 3px 3px 0 var(--bds-void); +} +.dlg-option.b { border-color: var(--bds-kevin); } +.dlg-option .opt-label { display: block; color: var(--bds-brad); font-size: 7px; + letter-spacing: 1px; margin-bottom: 6px; } +.dlg-option.b .opt-label { color: var(--bds-amber); } +.dlg-option:hover { background: var(--bds-white); } +.dlg-option:active { transform: translate(3px,3px); box-shadow: none; } + +.dlg-quick { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; } +.btn-quick { + background: var(--surface-hover); border: 2px solid var(--border-bright); + color: var(--text-heading); font-size: 7px; padding: 8px 10px; + box-shadow: 3px 3px 0 var(--bds-void); +} +.btn-quick:active { transform: translate(3px,3px); box-shadow: none; } +.btn-quick.danger { border-color: var(--bds-brad); color: var(--bds-brad); } + +.dlg-inputrow { display: flex; gap: 8px; } +.dlg-input { + flex: 1; background: var(--bds-white); border: 2px solid var(--border-bright); + color: var(--text-body); font-family: inherit; font-size: 8px; + padding: 10px; outline: none; +} +.dlg-input::placeholder { color: var(--text-disabled); } +.dlg-input:focus { border-color: var(--bds-neon-cyan); + box-shadow: 0 0 8px rgba(31,127,174,.4); } +.btn-send { + background: var(--bds-neon-magenta); border: 2px solid var(--bds-void); + color: var(--bds-white); font-size: 8px; padding: 10px 14px; + box-shadow: 3px 3px 0 var(--bds-void); +} +.btn-send:active { transform: translate(3px,3px); box-shadow: none; } + +.dlg-thinking { font-size: 9px; color: var(--text-muted); padding: 18px 0; + text-align: center; } +.dlg-thinking .dots::after { content: ""; animation: dots 1.2s steps(4) infinite; } +@keyframes dots { 0% { content: ""; } 25% { content: "."; } + 50% { content: ".."; } 75% { content: "..."; } } + +.dlg-after-you { border: 2px solid var(--border); padding: 8px; + font-size: 7px; color: var(--text-muted); margin-bottom: 8px; + background: var(--surface-hover); } +.dlg-after-react { border-left: 4px solid var(--npc-color, var(--border-bright)); + padding: 8px; font-size: 9px; line-height: 2; margin-bottom: 8px; + color: var(--text-body); } +.dlg-after-conseq { border-left: 4px solid var(--bds-success); padding: 8px; + font-size: 8px; line-height: 1.9; color: var(--text-body); + margin-bottom: 10px; } +.dlg-after-conseq.down { border-left-color: var(--bds-brad); } +.btn-next { + width: 100%; background: var(--bds-neon-magenta); border: 2px solid var(--bds-void); + color: var(--bds-white); font-size: 10px; padding: 14px; + box-shadow: 4px 4px 0 var(--bds-void); +} +.btn-next:active { transform: translate(4px,4px); box-shadow: none; } + +.round-dots { font-size: 10px; color: var(--bds-amber); letter-spacing: 4px; } +.board-badge { font-size: 7px; color: var(--bds-brad); + border: 2px solid var(--bds-brad); padding: 4px 6px; margin-left: auto; } +.wrong-slide { + background: var(--bds-janet-soft); border: 4px solid var(--bds-neon-magenta); + padding: 12px; text-align: center; margin-bottom: 10px; color: #5a3a50; + font-size: 8px; +} +.wrong-slide .ws-photo { width: 90px; height: 64px; background: #d8d0e2; + margin: 6px auto; border: 2px solid #9a90b0; position: relative; + display: flex; align-items: flex-end; justify-content: center; + overflow: visible; } +.wrong-slide .ws-title { font-size: 10px; color: var(--bds-neon-magenta); + font-style: italic; } + +/* ---- gift panel ---- */ +#gift-panel { + position: fixed; left: 50%; top: 50%; transform: translate(-50%,-50%); + background: var(--surface-card); + border: 4px solid var(--npc-color, var(--border-bright)); + box-shadow: 8px 8px 0 var(--bds-void); z-index: 210; padding: 14px; + width: 320px; +} +.gift-head { font-size: 9px; color: var(--npc-color, var(--text-heading)); + margin-bottom: 10px; } +.gift-balance { font-size: 7px; color: var(--bds-success); margin-bottom: 10px; } +.gift-row { + display: flex; justify-content: space-between; width: 100%; + background: var(--surface-hover); border: 2px solid var(--border-bright); + color: var(--text-body); font-size: 8px; padding: 10px; margin-bottom: 8px; + box-shadow: 2px 2px 0 var(--bds-void); +} +.gift-row:hover:not(:disabled) { border-color: var(--bds-success); + background: var(--bds-white); } +.gift-row:disabled { color: var(--text-disabled); border-color: var(--border); + box-shadow: none; cursor: not-allowed; } +.gift-cancel { font-size: 7px; color: var(--text-muted); background: none; + border: none; width: 100%; padding: 6px; } + +/* ---- boardroom ---- */ +#boardroom { position: absolute; inset: 0; z-index: 15; + background: var(--bds-purple-900); } + +/* ---- title screen ---- */ +#title-screen { + position: fixed; inset: 0; z-index: 300; + display: flex; align-items: center; justify-content: center; + /* soft vignette focuses the card and lifts it off the flat field */ + background: radial-gradient(ellipse at center, + var(--bds-purple-800) 0%, var(--bds-navy-800) 100%); +} +.ts-card { + text-align: center; max-width: 560px; padding: 32px 44px; + background: var(--surface-card); border: 4px solid var(--border-bright); + box-shadow: 10px 10px 0 var(--bds-void); +} +.ts-logo { + font-size: 38px; color: var(--text-heading); line-height: 1.4; + text-shadow: 4px 4px 0 var(--bds-amber); + animation: ts-logo 2.8s ease-in-out infinite; +} +@keyframes ts-logo { + 0%, 100% { transform: translateY(0); + text-shadow: 4px 4px 0 var(--bds-amber); } + 50% { transform: translateY(-3px); + text-shadow: 4px 4px 0 var(--bds-amber), 0 0 16px rgba(185,133,42,.55); } +} +.ts-sub { display: block; font-size: 9px; color: var(--bds-neon-cyan); + margin: 14px 0 18px; letter-spacing: 3px; + text-shadow: 1px 1px 0 rgba(36,31,23,.25); } +.ts-premise { font-size: 8px; line-height: 2.2; color: var(--text-body); + margin-bottom: 22px; } +.btn-cta { + background: var(--bds-neon-magenta); color: var(--bds-white); + border: 3px solid var(--bds-void); + font-size: 12px; padding: 16px 28px; box-shadow: 5px 5px 0 var(--bds-void); +} +.btn-cta:active { transform: translate(5px,5px); box-shadow: none; } +#btn-start { animation: ts-cta 1.9s ease-in-out infinite; } +@keyframes ts-cta { + 0%, 100% { box-shadow: 5px 5px 0 var(--bds-void); } + 50% { box-shadow: 5px 5px 0 var(--bds-void), 0 0 18px rgba(192,57,143,.6); } +} +.ts-keys { margin-top: 18px; font-size: 7px; color: var(--text-body); + line-height: 1.8; } +#ts-cast { display: flex; justify-content: center; gap: 14px; + margin-bottom: 22px; align-items: flex-end; } +/* the team idles in place, gently, staggered so it reads as a busy office */ +#ts-cast .cast-slot { text-align: center; animation: ts-bob 2.4s ease-in-out infinite; } +#ts-cast .cast-slot:nth-child(2) { animation-delay: .35s; } +#ts-cast .cast-slot:nth-child(3) { animation-delay: .7s; } +#ts-cast .cast-slot:nth-child(4) { animation-delay: 1.05s; } +#ts-cast .cast-slot:nth-child(5) { animation-delay: 1.4s; } +@keyframes ts-bob { 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-5px); } } +/* ink shadow keeps even the muted gold/blue names legible on cream */ +#ts-cast .cast-name { font-size: 6px; margin-top: 6px; letter-spacing: 1px; + text-shadow: 1px 1px 0 var(--bds-void); } + +/* ---- review screen ---- */ +#review-screen { + position: fixed; inset: 0; background: var(--bg-app); z-index: 290; + display: flex; align-items: center; justify-content: center; +} +.rv-card { + width: min(680px, 94vw); max-height: 92vh; overflow-y: auto; + border: 6px solid var(--rv-color, var(--border-bright)); padding: 22px; + background: var(--surface-card); + box-shadow: 8px 8px 0 var(--bds-void); +} +.rv-head { font-size: 14px; color: var(--text-heading); margin-bottom: 16px; + letter-spacing: 2px; } +.rv-revenue { font-size: 28px; color: var(--rv-color, var(--text-heading)); + margin-bottom: 6px; } +.rv-gap { font-size: 8px; color: var(--text-muted); margin-bottom: 14px; } +.rv-line { font-size: 8px; line-height: 2.2; color: var(--text-body); } +.rv-title { color: var(--bds-janet); } +.rv-section { margin: 14px 0; border-top: 2px solid var(--border); + padding-top: 12px; } +.rv-sec-head { font-size: 8px; color: var(--text-muted); letter-spacing: 2px; + margin-bottom: 8px; } +.rv-verdict { font-size: 9px; line-height: 2.1; color: var(--bds-amber); } + +/* ---- ambient light wash (replaces CRT — daylight DS, no scanlines) ---- */ +#crt { + position: fixed; inset: 0; pointer-events: none; z-index: 999; + background: radial-gradient(ellipse at 50% 0%, + rgba(255,248,226,.10) 0%, transparent 55%); +} + +/* ---- reduced motion + mobile ---- */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01s !important; + transition-duration: .01s !important; } +} +@media (max-width: 760px) { + #papertrail { display: none; } + #rev-bar { width: 140px; } + #hud { flex-wrap: wrap; gap: 8px 12px; padding: 6px 10px; } + #hud-right { gap: 10px; } + #wordmark { padding: 6px 10px; gap: 10px; } + .wm-main { font-size: 11px; } + .dlg-options { flex-direction: column; } + #dialogue { width: 96vw; max-height: 56vh; padding: 11px; } + .ts-card, .rv-card { max-width: 94vw; max-height: 88vh; overflow-y: auto; } + .comic-caption { font-size: 9px; } +} + +/* ---- touch controls (created by touch.js only on touch devices) ---- */ +#tc-stick, #tc-buttons { + position: fixed; bottom: 20px; z-index: 150; + touch-action: none; user-select: none; -webkit-user-select: none; +} +#tc-stick { + left: 20px; width: 122px; height: 122px; border-radius: 50%; + background: rgba(36, 31, 23, .20); border: 3px solid rgba(36, 31, 23, .45); + display: flex; align-items: center; justify-content: center; +} +.tc-thumb { + width: 52px; height: 52px; border-radius: 50%; + background: var(--bds-amber); border: 3px solid var(--bds-void); + box-shadow: 0 3px 0 rgba(36, 31, 23, .4); +} +#tc-buttons { right: 20px; display: flex; gap: 14px; align-items: center; } +.tc-btn { + font-family: var(--font-display, "Press Start 2P", monospace); + border: 3px solid var(--bds-void); color: var(--bds-void); padding: 0; + box-shadow: 0 4px 0 rgba(36, 31, 23, .4); +} +.tc-btn.down { transform: translateY(3px); box-shadow: 0 1px 0 rgba(36, 31, 23, .4); } +.tc-action { + width: 88px; height: 88px; border-radius: 50%; font-size: 13px; + background: var(--bds-success, #3fae62); color: var(--bds-white, #fff); +} +.tc-gift { + width: 62px; height: 62px; border-radius: 50%; font-size: 9px; + background: var(--bds-amber); +} +/* hide the controls whenever a menu / dialogue / comic owns the screen */ +:is(#title-screen, #review-screen, #dialogue, #comic, #gift-panel):not(.hidden) ~ #tc-stick, +:is(#title-screen, #review-screen, #dialogue, #comic, #gift-panel):not(.hidden) ~ #tc-buttons { + display: none; +} diff --git a/static/css/tokens.css b/static/css/tokens.css new file mode 100644 index 0000000000000000000000000000000000000000..5cb8eeb34321e453d5e17bb5e74d3c97a6652bab --- /dev/null +++ b/static/css/tokens.css @@ -0,0 +1,337 @@ +/* ============================================================ + BASE — Brad Did Something + Element defaults so any consumer page reads as the game by + default: warm paper canvas, dark-ink headings, warm body text, + clean (no scanlines). + ============================================================ */ + +*, *::before, *::after { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + background: var(--bg-app); + color: var(--text-body); + font-family: var(--font-body); + font-size: var(--fs-body); + line-height: var(--lh-body); + letter-spacing: var(--ls-normal); + -webkit-font-smoothing: none; /* keep pixels crunchy */ + font-smooth: never; + text-rendering: optimizeSpeed; +} + +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-display); + color: var(--text-heading); + line-height: var(--lh-tight); + margin: 0 0 var(--space-4); + font-weight: 400; +} +h1 { font-size: var(--fs-h1); } +h2 { font-size: var(--fs-h2); } +h3 { font-size: var(--fs-h3); } + +p { margin: 0 0 var(--space-4); text-wrap: pretty; } + +a { + color: var(--link); + text-decoration: none; + border-bottom: 2px solid currentColor; +} +a:hover { text-shadow: var(--text-glow-cyan); } + +:focus-visible { + outline: none; + box-shadow: var(--focus-shadow); +} + +code, kbd, samp { font-family: var(--font-mono); } + +img[data-pixel-img], .pixel-art { image-rendering: pixelated; } + +/* ---------- Utility: arcade marquee text ---------- */ +.bds-marquee { + color: var(--text-heading); + text-shadow: var(--text-glow-magenta); +} +.bds-terminal { color: var(--text-body); } +.bds-money { color: var(--text-money); text-shadow: var(--text-glow-lime); } + +/* ---------- Utility: full-screen daylight shell ---------- + Wrap a page in
to get the warm paper + canvas + soft top-light automatically. (CRT is retired.) */ +.bds-screen { + position: relative; + min-height: 100vh; + background: + radial-gradient(ellipse at 50% -10%, var(--bds-purple-900) 0%, var(--bg-app) 60%); + overflow: clip; +} +/* ============================================================ + COLORS — Brad Did Something · DAYLIGHT 3/4 re-skin + Warm, sunlit office: tiled warm-grey floors, cream paper + panels, dark warm INK for outlines + text, the five + character colors muted to read in daylight. No CRT, no neon + void. (--bds-void stays dark = it is the INK; --bds-white + stays light = highlights / player / light fills.) + ============================================================ */ + +:root { + /* ---- INK (dark, warm) — outlines, borders, hard edges, text ---- */ + --bds-void: #241f17; /* the ink line behind everything */ + --bds-ink: #2b2519; /* near-ink */ + --bds-ink-2: #4b4534; /* body text */ + --bds-ink-3: #7c755f; /* muted label */ + --bds-ink-4: #a79f88; /* disabled / ghost */ + + /* ---- PAPER ramp (warm light) — was the navy/purple ramp. + darkest = desktop behind panels · lightest = hover ---- */ + --bds-navy-900: #b7b1a0; /* app background (warm grey carpet) */ + --bds-navy-800: #a59e8b; /* recessed wells */ + --bds-purple-900: #ccc6b5; /* alt background band */ + --bds-purple-800: #d7d1c0; /* raised surface */ + --bds-purple-700: #e9e4d3; /* card surface (cream) */ + --bds-purple-600: #f4f0e2; /* hover surface */ + + /* ---- Light ink: highlights, player, light fills ---- */ + --bds-white: #faf7ee; /* warm white */ + --bds-phosphor: #3f5a44; /* terminal/log text (dark sage, reads on paper) */ + --bds-phosphor-dim:#6a7d62; /* muted terminal text */ + --bds-amber: #b9852a; /* warning / scrutiny gold (reads on paper) */ + --bds-grey: #7c755f; /* low-emphasis label */ + --bds-grey-dim: #a79f88; /* disabled / ghost ink */ + + /* ---- The five underlings — muted for daylight ---- */ + --bds-brad: #d2593a; /* warm orange-red — the problem */ + --bds-stacey: #1f9c8e; /* teal — the competent one */ + --bds-kevin: #cf9a2a; /* gold — the pie chart guy */ + --bds-janet: #9a52c4; /* purple — the wildcard */ + --bds-derek: #4f93c4; /* blue — barely present */ + + /* character tints — light wash behind portraits */ + --bds-brad-soft: #f0d8cd; + --bds-stacey-soft: #d2e9e4; + --bds-kevin-soft: #efe4c4; + --bds-janet-soft: #e7dcf0; + --bds-derek-soft: #d6e6f2; + + /* ---- System accents — daylight-legible ---- */ + --bds-neon-magenta:#c0398f; /* primary accent / CTA (raspberry) */ + --bds-neon-cyan: #1f7fae; /* links, focus, selection (sea blue) */ + --bds-neon-lime: #3f9a3f; /* success / money (forest green) */ + + /* ---- Semantic status ---- */ + --bds-success: #3f9a3f; /* revenue went up (rare) */ + --bds-warning: #cf9a2a; /* a Brad is forming */ + --bds-danger: #d2593a; /* a Brad has formed */ + --bds-info: #4f93c4; + + /* ============================================================ + SEMANTIC ALIASES — design against these, not the raw scale + ============================================================ */ + --bg-app: var(--bds-navy-900); + --bg-band: var(--bds-purple-900); + --bg-well: var(--bds-navy-800); + --surface-raised: var(--bds-purple-800); + --surface-card: var(--bds-purple-700); + --surface-hover: var(--bds-purple-600); + + --text-heading: var(--bds-ink); + --text-body: var(--bds-ink-2); + --text-muted: var(--bds-ink-3); + --text-disabled: var(--bds-ink-4); + --text-money: var(--bds-success); + + --accent: var(--bds-neon-magenta); + --accent-2: var(--bds-neon-cyan); + --link: var(--bds-neon-cyan); + --focus-ring: var(--bds-neon-cyan); + --selection: var(--bds-neon-magenta); + + --border: #b3ab95; /* soft divider / inset edge */ + --border-bright: #3a3326; /* the dark pixel frame */ + --border-neon: var(--bds-neon-magenta); +} + +::selection { + background: var(--selection); + color: var(--bds-white); +} +/* ============================================================ + FONTS — Brad Did Something + One typeface to rule them all: Press Start 2P. + A bitmap arcade font (Namco, 1980s). Looks best at multiples + of 8px. We ship the latin woff2 locally so consumers are + offline-safe. + ============================================================ */ + +@font-face { + font-family: "Press Start 2P"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/PressStart2P-Regular.woff2") format("woff2"); +} +/* ============================================================ + TYPOGRAPHY — Brad Did Something + Everything is Press Start 2P. Headlines = arcade marquee. + Body = crashed terminal. Sizes are multiples of 8 because + the font is a bitmap and renders crisp on the grid. + ============================================================ */ + +:root { + --font-pixel: "Press Start 2P", "Courier New", monospace; + --font-display: var(--font-pixel); + --font-body: var(--font-pixel); + --font-mono: var(--font-pixel); + + /* ---- Type scale (px, multiples of 8 where possible) ---- + Press Start 2P runs LARGE and WIDE per glyph, so the body + sizes are small numerically but read big. Never go below + 8px; UI body lives at 10–12px, prose at 12px. */ + --fs-display: 40px; /* hero / title screen */ + --fs-h1: 28px; /* screen titles, arcade marquee */ + --fs-h2: 20px; /* section headers */ + --fs-h3: 16px; /* sub-headers */ + --fs-body: 12px; /* default body / dialogue */ + --fs-ui: 10px; /* buttons, labels, HUD readouts */ + --fs-fine: 8px; /* legal-ish memo footer, terminal logs */ + + /* ---- Line height ---- + Pixel fonts need air. 1.8–2.0 keeps the scanline rhythm. */ + /* line-height — pixel fonts need air; 1.8–2.0 keeps scanline rhythm */ + --lh-tight: 1.4; /* @kind other */ + --lh-body: 1.9; /* @kind other */ + --lh-loose: 2.2; /* @kind other */ + + /* ---- Letter spacing ---- + The glyphs are already monospaced & square; only nudge. */ + --ls-tight: -0.5px; + --ls-normal: 0px; + --ls-wide: 2px; /* HUD labels, all-caps tags */ + + /* ---- Semantic roles ---- */ + --text-display-size: var(--fs-display); + --text-h1-size: var(--fs-h1); + --text-body-size: var(--fs-body); + --text-ui-size: var(--fs-ui); +} + +/* Pixel fonts must NOT be anti-aliased into mush. Keep edges + crisp; let the CRT overlay do the softening. */ +:root { + --pixel-render: pixelated; /* @kind other */ +} +/* ============================================================ + SPACING — Brad Did Something + 8px grid. The font lives on it; so does everything else. + No fractional spacing. Pixels are sacred. + ============================================================ */ + +:root { + --space-0: 0px; + --space-1: 4px; /* hairline gap (half-step, use sparingly) */ + --space-2: 8px; /* base unit */ + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + --space-7: 48px; + --space-8: 64px; + --space-9: 96px; + + /* ---- Radius ---- + Pixel UI = mostly hard corners. We allow a single 4px + "chunky pixel" rounding for soft chips; never smooth. */ + --radius-0: 0px; /* default — everything is square */ + --radius-chunk:4px; /* chips, avatars, soft cards */ + --radius-pill: 0px; /* there are no pills here */ + + /* ---- Border widths (the pixel frame) ---- */ + --bw-1: 2px; /* default UI border */ + --bw-2: 4px; /* emphasized frame / dialogue boxes */ + --bw-3: 6px; /* HUD chrome, window bezels */ + + /* ---- Containers ---- */ + --container-screen: 1280px; + --container-narrow: 720px; + --hud-bar-height: 64px; +} +/* ============================================================ + EFFECTS — Brad Did Something + CRT scanlines, neon glow, hard pixel shadows, glitch. + Shadows are HARD-OFFSET (no blur) to read as 8-bit. Glow + is the ONLY place blur is allowed. + ============================================================ */ + +:root { + /* ---- Hard pixel shadows (offset, zero blur) — warm, on paper ---- */ + --shadow-pixel: 4px 4px 0 0 rgba(36,31,23,0.28); + --shadow-pixel-lg: 8px 8px 0 0 rgba(36,31,23,0.26); + --shadow-pixel-sm: 2px 2px 0 0 rgba(36,31,23,0.30); + + /* ---- Accent glow — kept SUBTLE in daylight (small, low-alpha) ---- */ + --glow-magenta: 0 0 7px rgba(192,57,143,0.35); + --glow-cyan: 0 0 7px rgba(31,127,174,0.35); + --glow-lime: 0 0 7px rgba(63,154,63,0.35); + --glow-soft: 0 0 6px rgba(120,100,70,0.25); + + /* text glow for arcade marquee headings — now a soft warm halo */ + --text-glow-magenta: 0 0 5px rgba(192,57,143,0.28); + --text-glow-cyan: 0 0 5px rgba(31,127,174,0.28); + --text-glow-lime: 0 0 5px rgba(63,154,63,0.28); + + /* ---- Focus ---- */ + --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); + + /* ---- CRT scanline overlay ---- + Apply as a fixed/absolute ::after layer. Two-layer: + horizontal scanlines + a faint vignette. pointer-events:none. */ + /* DAYLIGHT re-skin: CRT is retired. These are neutralized to + transparent so any leftover .crt-overlay / [data-crt] renders + nothing (kept as vars so old markup doesn't error). */ + --crt-scanline-size: 3px; /* line period */ + --crt-scanline: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0)); /* @kind other */ + --crt-tint: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0)); + + /* ---- Motion ---- */ + --ease-step: steps(4, end); /* @kind other */ + --ease-pop: cubic-bezier(0.2,1.4,0.4,1); /* @kind other */ + --dur-fast: 90ms; /* @kind other */ + --dur: 160ms; /* @kind other */ + --dur-slow: 320ms; /* @kind other */ +} + +/* ---------- Reusable CRT overlay ---------- + Put
as a fixed child of a + relatively/fixed positioned root, OR add data-crt to a box. */ +.crt-overlay, +[data-crt]::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 9999; + background: var(--crt-scanline), var(--crt-tint); + mix-blend-mode: multiply; +} +[data-crt] { position: relative; } + +/* faint flicker — disabled under reduced motion */ +@media (prefers-reduced-motion: no-preference) { + @keyframes bds-flicker { + 0%, 97%, 100% { opacity: 1; } + 98% { opacity: 0.82; } + 99% { opacity: 0.94; } + } + .crt-overlay { animation: bds-flicker 6s steps(1,end) infinite; } +} + +/* ---------- Pixel-perfect rendering helper ---------- */ +[data-pixel-img] { + image-rendering: pixelated; + image-rendering: crisp-edges; +} + diff --git a/static/fonts/PressStart2P-Regular.woff2 b/static/fonts/PressStart2P-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..907f56c9611c991cb64ab9b46a2fa28e0bfc9964 --- /dev/null +++ b/static/fonts/PressStart2P-Regular.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:854e91989d45c8148a3c17b67e0ec0925012db61fe8d7a9e04593883f105db72 +size 4716 diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000000000000000000000000000000000000..74a62fbf690dba46b6c5272110c0a01eecf3ac42 --- /dev/null +++ b/static/index.html @@ -0,0 +1,77 @@ + + + + + +BRAD DID SOMETHING + + + + +
+
+ BRAD DID SOMETHING + VELOURA TECHNOLOGIES · Q3 +
+ + + +
+
+
+ +
+
+ + +
+
+
+ +
+
+ +
+ + + + + +
+
+ +
VELOURA TECHNOLOGIES
+

You are the Head of Sales and Partnerships. Your team + is enthusiastic, well-intentioned, and completely unhinged. Hit $1,000,000 + this quarter. Your people will keep happening.

+
+ +
> WASD move · SPACE talk · G gift · 1/2 choose · ENTER send
+
+
+ + +
+
+ + + + + diff --git a/static/js/api.js b/static/js/api.js new file mode 100644 index 0000000000000000000000000000000000000000..8e164e00f23c7e2d266578f7fbff04a34429b349 --- /dev/null +++ b/static/js/api.js @@ -0,0 +1,33 @@ +// fetch wrappers for the /api endpoints (SCHEMAS.md HTTP contracts) +async function post(path, body) { + const resp = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body || {}), + }); + if (!resp.ok) { + const detail = await resp.json().catch(() => ({})); + const err = new Error(detail.detail || `http ${resp.status}`); + err.status = resp.status; + throw err; + } + return resp.json(); +} + +export const warm = () => post("/api/warm"); +export const newGame = () => post("/api/new_game"); +export const nextEvent = (sid) => post("/api/next_event", { session_id: sid }); +export const respond = (sid, response_type, text) => + post("/api/respond", { session_id: sid, response_type, text: text || "" }); +export const presentationRound = (sid, response_type, text) => + post("/api/presentation_round", + { session_id: sid, response_type: response_type || "custom", text: text || "" }); +export const gift = (sid, npc_id, tier) => + post("/api/gift", { session_id: sid, npc_id, tier }); +export const review = (sid) => post("/api/review", { session_id: sid }); +export const chat = (sid, npc_id, text) => + post("/api/chat", { session_id: sid, npc_id, text: text || "" }); +export const idle = (sid) => post("/api/idle", { session_id: sid }); +export const readEmail = (sid) => post("/api/read_email", { session_id: sid }); +export const comic = (sid, image_prompt) => + post("/api/comic", { session_id: sid, image_prompt: image_prompt || "" }); diff --git a/static/js/audio.js b/static/js/audio.js new file mode 100644 index 0000000000000000000000000000000000000000..6af425cdb03618640b161b73d2fbb24c67cbbc9c --- /dev/null +++ b/static/js/audio.js @@ -0,0 +1,277 @@ +// Cozy WebAudio sound — warm and quiet, not techy. Sine/triangle voices run +// through a soft lowpass + a little reverb; notes are pentatonic so nothing +// ever clashes; envelopes fade in/out so there are no clicks. Low master gain. +// No audio files. Mute with M. + +let ctx = null, bus = null, master = null, muted = false, dead = false; +const MASTER = 0.32; // overall cosiness ceiling — everything sits under this + +// looping background music (mp3 in static/audio/). Routed past the SFX +// lowpass/reverb so it stays full, but still under master + M mute. +const MUSIC_SRC = "/static/audio/bgm.mp3"; +const MUSIC_VOL = 0.15; // sits gently under the SFX — tune to taste +let musicEl = null, musicGain = null, musicWired = false; + +function ac() { + if (dead) return null; + try { + if (!ctx) { + ctx = new (window.AudioContext || window.webkitAudioContext)(); + master = ctx.createGain(); + master.gain.value = MASTER; + master.connect(ctx.destination); + // warm everything: a gentle lowpass shaves the harsh top end + const lp = ctx.createBiquadFilter(); + lp.type = "lowpass"; lp.frequency.value = 2600; lp.Q.value = 0.5; + lp.connect(master); + // a little room: short generated reverb, mixed in low + const wet = ctx.createGain(); wet.gain.value = 0.16; + const verb = ctx.createConvolver(); verb.buffer = impulse(0.9, 2.6); + verb.connect(wet); wet.connect(master); + bus = ctx.createGain(); bus.gain.value = 1; + bus.connect(lp); // dry + bus.connect(verb); // wet + } + if (ctx.state === "suspended") ctx.resume(); + return ctx; + } catch { + dead = true; // no audio device — game keeps working, silently + return null; + } +} + +function impulse(seconds, decay) { + const a = ctx, len = Math.floor(a.sampleRate * seconds); + const buf = a.createBuffer(2, len, a.sampleRate); + for (let ch = 0; ch < 2; ch++) { + const d = buf.getChannelData(ch); + for (let i = 0; i < len; i++) + d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay); + } + return buf; +} + +// pentatonic C-major — any mix of these sounds pleasant and warm +const N = { + C3: 130.81, E3: 164.81, G3: 196.0, + C4: 261.63, D4: 293.66, E4: 329.63, G4: 392.0, A4: 440.0, + C5: 523.25, D5: 587.33, E5: 659.25, G5: 783.99, A5: 880.0, + C6: 1046.5, D6: 1174.7, E6: 1318.5, G6: 1568.0, +}; + +// one soft voice with a click-free envelope +function tone(freq, o = {}) { + if (muted) return; + const a = ac(), t0 = a.currentTime + (o.t || 0); + const dur = o.dur || 0.2, atk = o.attack || 0.012, rel = o.rel || dur * 0.85; + const osc = a.createOscillator(); + osc.type = o.type || "sine"; + osc.frequency.setValueAtTime(freq, t0); + if (o.glideTo) osc.frequency.exponentialRampToValueAtTime(o.glideTo, t0 + dur); + if (o.detune) osc.detune.value = o.detune; + const g = a.createGain(); + g.gain.setValueAtTime(0.0001, t0); + g.gain.exponentialRampToValueAtTime(o.gain || 0.1, t0 + atk); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + atk + rel); + osc.connect(g).connect(bus); + osc.start(t0); osc.stop(t0 + atk + rel + 0.05); + // a faint octave/fifth layer warms plucks without making them louder + if (o.warm) tone(freq * o.warm, { ...o, warm: 0, gain: (o.gain || 0.1) * 0.35 }); +} + +// filtered noise — footsteps, paper, whooshes (cloth/wood, never beeps) +function noise(o = {}) { + if (muted) return; + const a = ac(), t0 = a.currentTime + (o.t || 0), dur = o.dur || 0.08; + const buf = a.createBuffer(1, Math.floor(a.sampleRate * dur), a.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; + const src = a.createBufferSource(); src.buffer = buf; + const f = a.createBiquadFilter(); + f.type = o.filter || "lowpass"; + f.frequency.setValueAtTime(o.cutoff || 700, t0); + f.Q.value = o.q || 0.7; + if (o.sweep) f.frequency.linearRampToValueAtTime(o.sweep, t0 + dur); + const g = a.createGain(); + g.gain.setValueAtTime(0.0001, t0); + g.gain.exponentialRampToValueAtTime(o.gain || 0.04, t0 + (o.attack || 0.005)); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + src.connect(f).connect(g).connect(bus); + src.start(t0); src.stop(t0 + dur + 0.02); +} + +// play a little melody/arpeggio +function seq(freqs, gap, o = {}) { + freqs.forEach((f, i) => tone(f, { ...o, t: (o.t || 0) + i * gap })); +} + +// ---- ambient office bed: a near-silent warm drone, always running ---- +let bed = null; +function ambientStart() { + if (bed) return; + const a = ac(); + const g = a.createGain(); g.gain.value = 0.0001; g.connect(bus); + g.gain.exponentialRampToValueAtTime(0.013, a.currentTime + 3); + const f = a.createBiquadFilter(); + f.type = "lowpass"; f.frequency.value = 380; f.Q.value = 0.4; f.connect(g); + const o1 = a.createOscillator(); o1.type = "sine"; o1.frequency.value = N.C3; + const o2 = a.createOscillator(); o2.type = "sine"; o2.frequency.value = N.C3 + 0.6; + const o3 = a.createOscillator(); o3.type = "sine"; o3.frequency.value = N.G3; + const g3 = a.createGain(); g3.gain.value = 0.4; + o1.connect(f); o2.connect(f); o3.connect(g3).connect(f); + // a very slow filter drift so it breathes + const lfo = a.createOscillator(); lfo.frequency.value = 0.06; + const lg = a.createGain(); lg.gain.value = 60; + lfo.connect(lg).connect(f.frequency); + [o1, o2, o3, lfo].forEach((x) => x.start()); + bed = { g, nodes: [o1, o2, o3, lfo] }; +} +function ambientStop() { + if (!bed) return; + const a = ac(), { g, nodes } = bed; + g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime + 1.2); + nodes.forEach((x) => { try { x.stop(a.currentTime + 1.4); } catch {} }); + bed = null; +} + +// ---- looping background music (mp3) ---- +function musicStart() { + try { + if (!musicEl) { + musicEl = new Audio(MUSIC_SRC); + musicEl.loop = true; musicEl.preload = "auto"; + musicEl.style.display = "none"; + document.body.appendChild(musicEl); // attach for robustness + inspection + } + musicEl.muted = muted; + const a = ac(); + if (a && !musicWired) { + try { + const src = a.createMediaElementSource(musicEl); + musicGain = a.createGain(); musicGain.gain.value = 0.0001; + src.connect(musicGain).connect(master); // past the SFX lowpass/reverb + musicGain.gain.exponentialRampToValueAtTime(MUSIC_VOL, a.currentTime + 2.5); + musicWired = true; + } catch { musicEl.volume = MUSIC_VOL; } + } else if (!a) { + musicEl.volume = MUSIC_VOL; // no WebAudio — plain element + } else if (musicGain) { + musicGain.gain.exponentialRampToValueAtTime(MUSIC_VOL, a.currentTime + 1.5); + } + const p = musicEl.play(); + if (p && p.catch) p.catch(() => {}); // autoplay block — ignore + } catch {} +} +function musicStop() { + if (!musicEl) return; + try { + if (musicGain && ctx) { + musicGain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 1.2); + setTimeout(() => { try { musicEl.pause(); } catch {} }, 1300); + } else { musicEl.pause(); } + } catch {} +} + +// ---- rate limiters so footsteps / typing never stack into a buzz ---- +let lastFoot = 0, footToggle = 0, lastType = 0; + +export const sfx = { + // movement & ambient + footstep() { + const now = performance.now(); + if (now - lastFoot < 250) return; + lastFoot = now; footToggle ^= 1; + noise({ dur: 0.06, cutoff: footToggle ? 540 : 600, q: 1.1, gain: 0.022 }); + tone(footToggle ? N.C3 : N.E3, { type: "sine", dur: 0.05, gain: 0.012 }); + }, + bump() { noise({ dur: 0.11, cutoff: 300, gain: 0.03 }); + tone(90, { type: "sine", dur: 0.12, gain: 0.035, glideTo: 70 }); }, + ambientStart, ambientStop, musicStart, musicStop, + + // dialogue & UI + open() { seq([N.E5, N.A5], 0.06, { type: "sine", gain: 0.06, dur: 0.22 }); }, + close() { seq([N.A5, N.E5], 0.06, { type: "sine", gain: 0.045, dur: 0.2 }); }, + type() { + const now = performance.now(); + if (now - lastType < 55) return; + lastType = now; + tone(350 + Math.random() * 36, { type: "triangle", dur: 0.035, + gain: 0.016, attack: 0.002 }); + }, + click() { tone(N.A5, { type: "triangle", dur: 0.1, gain: 0.05, warm: 0.5 }); }, + send() { seq([N.G5, N.C6], 0.05, { type: "sine", gain: 0.06, dur: 0.16 }); }, + prompt() { tone(N.D6, { type: "sine", dur: 0.12, gain: 0.028 }); }, + comic() { noise({ dur: 0.34, cutoff: 420, sweep: 1800, gain: 0.04 }); // page reveal + seq([N.G5, N.C6, N.E6], 0.07, { type: "sine", gain: 0.04, dur: 0.26, warm: 0.5 }); }, + + // event arrivals + bubble() { tone(N.E5, { type: "sine", dur: 0.2, gain: 0.06, glideTo: N.A5 }); }, + amberBubble() { tone(N.D5, { type: "sine", dur: 0.22, gain: 0.05, glideTo: N.G4 }); }, + heart() { seq([N.E5, N.A5, N.C6], 0.07, { type: "sine", gain: 0.045, dur: 0.24 }); + tone(N.E6, { t: 0.2, dur: 0.32, gain: 0.022 }); }, + newspaper() { noise({ dur: 0.4, cutoff: 380, sweep: 1700, gain: 0.04 }); + noise({ t: 0.32, dur: 0.05, cutoff: 2400, gain: 0.03 }); + noise({ t: 0.4, dur: 0.05, cutoff: 2000, gain: 0.025 }); }, + envelope() { noise({ dur: 0.3, cutoff: 1100, sweep: 520, gain: 0.035, + filter: "bandpass", q: 0.8 }); }, + mail() { seq([N.G5, N.C6], 0.13, { type: "sine", gain: 0.05, dur: 0.32, warm: 0.5 }); }, + phoneRingStart() { + if (sfx._ring) return; + const ring = () => { seq([N.E5, N.G5], 0.12, { type: "sine", gain: 0.04, dur: 0.22 }); + seq([N.E5, N.G5], 0.12, { type: "sine", gain: 0.04, dur: 0.22, t: 0.3 }); }; + ring(); sfx._ring = setInterval(ring, 1400); + }, + phoneRingStop() { if (sfx._ring) { clearInterval(sfx._ring); sfx._ring = null; } }, + + // outcomes + moneyUp() { seq([N.C5, N.E5, N.G5, N.C6], 0.06, + { type: "triangle", gain: 0.055, dur: 0.24, warm: 0.5 }); }, + moneyDown() { seq([N.A5, N.G5, N.E5, N.C5], 0.07, + { type: "sine", gain: 0.05, dur: 0.26 }); }, + disaster() { noise({ dur: 0.28, cutoff: 220, gain: 0.05 }); + tone(72, { type: "sine", dur: 0.4, gain: 0.05, glideTo: 54 }); }, + titleSwap() { seq([N.D6, N.G6], 0.04, { type: "sine", gain: 0.02, dur: 0.12 }); }, + trail() { noise({ dur: 0.04, cutoff: 3200, gain: 0.013, filter: "highpass" }); }, + gift() { seq([N.G5, N.A5, N.C6], 0.05, { type: "triangle", gain: 0.045, dur: 0.2 }); }, + coffee() { [N.C5, N.E5, N.G5].forEach((f) => + tone(f, { type: "sine", gain: 0.038, dur: 0.5 })); }, + + // boardroom / presentation + gold() { seq([N.G5, N.D6, N.G6], 0.08, { type: "sine", gain: 0.045, dur: 0.5 }); }, + wipe() { noise({ dur: 0.35, cutoff: 550, sweep: 1900, gain: 0.035 }); }, + boardIn() { noise({ dur: 0.08, cutoff: 380, gain: 0.03 }); + noise({ t: 0.16, dur: 0.08, cutoff: 420, gain: 0.03 }); }, + wrongSlide() { tone(N.G5, { type: "triangle", dur: 0.42, gain: 0.06, glideTo: N.D5 }); + tone(N.E5, { t: 0.4, type: "triangle", dur: 0.32, gain: 0.05, glideTo: N.C5 }); }, + score() { seq([N.C5, N.E5, N.G5], 0.08, { type: "triangle", gain: 0.05, dur: 0.2 }); + tone(N.C6, { t: 0.28, dur: 0.5, gain: 0.05, warm: 0.5 }); }, + stamp() { noise({ dur: 0.18, cutoff: 280, gain: 0.05 }); + tone(110, { type: "sine", dur: 0.26, gain: 0.05, glideTo: 88 }); }, + + // endings + win() { seq([N.C5, N.E5, N.G5, N.C6, N.E6, N.G6], 0.09, + { type: "triangle", gain: 0.055, dur: 0.42, warm: 0.5 }); + [N.C5, N.E5, N.G5].forEach((f) => tone(f, { t: 0.6, dur: 0.9, gain: 0.04 })); }, + lose(tier) { + const sets = { + hit_target: [N.C5, N.E5, N.G5], above_600k: [N.A4, N.G4, N.E4], + "300k_to_600k": [N.G4, N.E4, N.C4], below_300k: [N.E4, N.D4, N.C4, N.C3], + }; + seq(sets[tier] || sets["300k_to_600k"], 0.22, + { type: "sine", gain: 0.05, dur: 0.4 }); }, + confetti() { for (let i = 0; i < 12; i++) { + const notes = [N.C6, N.D6, N.E6, N.G6, N.A5]; + tone(notes[(Math.random() * notes.length) | 0], + { t: Math.random() * 1.1, type: "sine", dur: 0.18, gain: 0.03 }); } }, + review() { [N.C4, N.G4, N.C5].forEach((f, i) => + tone(f, { type: "sine", dur: 1.1, gain: 0.035, t: i * 0.08 })); }, + play() { seq([N.G5, N.C6], 0.07, { type: "sine", gain: 0.05, dur: 0.25 }); }, + + toggleMute() { + muted = !muted; + if (master) master.gain.exponentialRampToValueAtTime( + muted ? 0.0001 : MASTER, (ctx ? ctx.currentTime : 0) + 0.15); + if (musicEl) musicEl.muted = muted; // covers both routed + plain-element paths + return muted; + }, +}; diff --git a/static/js/boardroom.js b/static/js/boardroom.js new file mode 100644 index 0000000000000000000000000000000000000000..fc87ae3d1abe8f830c3f80f0d4666e51c94c9262 --- /dev/null +++ b/static/js/boardroom.js @@ -0,0 +1,159 @@ +// boardroom interior scene (UI_UX.md §6 §17 §21) +import { G } from "./state.js"; +import { chibiInBox } from "./sprites.js"; + +// presenting NPC expression per PRESENTATION_SYSTEM.md state +const PRESENT_EXPRESSION = { + romance: { eyes: "heart", blush: true, mouth: "smile" }, + grief: { eyes: "closed", mouth: "frown", tilt: 4 }, + overprepared: { armPose: "point", mouth: "open" }, + bare_minimum: { armPose: "crossed", mouth: "flat" }, + advocate: { eyes: "happy", mouth: "smile", armPose: "open" }, + normal: { armPose: "point", mouth: "smirk" }, +}; + +const HAIRS = ["#4a485e", "#6b6347", "#3a3850"]; + +function slideHtml(kind) { + if (kind === "pie140") { + return `
+
+
+ TOTAL: 140%
`; + } + if (kind === "momentum") + return `
MOMENTUM
(Trust Us)
`; + return `
+
+
[no title]
`; +} + +export function enterBoardroom(presentingNpc, npcState) { + const room = G.els.boardroom; + const chairAt = (x, y) => ` +
`; + const windowAt = (x, w) => ` +
+
+
+
+
+
+
+
`; + room.innerHTML = ` + +
+ +
+ +
+ ${windowAt(40, 70)}${windowAt(470, 70)} + +
+
+
+
+
+
+ ${chairAt(210, 122)}${chairAt(300, 122)}${chairAt(390, 122)} + ${chairAt(210, 282)}${chairAt(300, 282)}${chairAt(390, 282)} + ${chairAt(132, 196)} + +
+ ${slideHtml("pie140")}
+ +
+
+
+ +
+
+
+
+
+
> kevin watching (always)
`; + room.classList.remove("hidden"); + + // board members file in one at a time — full designed chibis, seated + // behind the table (the table's z-index covers their legs) + const seats = room.querySelector("#board-seats"); + seats.style.zIndex = "1"; + [188, 288, 388].forEach((x, i) => { + setTimeout(() => { + const member = chibiInBox("board", { + face: { hair: HAIRS[i], woman: i === 1 }, + mouth: i === 2 ? "frown" : "flat", + }, 70, 78); + member.style.position = "absolute"; + member.style.left = `${x}px`; + member.style.top = "100px"; + seats.appendChild(member); + }, 350 * i); + }); + + // presenting NPC at the projector — their emotional state shows + const colorVar = `var(--bds-${presentingNpc})`; + const npc = chibiInBox(presentingNpc, PRESENT_EXPRESSION[npcState] || + PRESENT_EXPRESSION.normal, 80, 86); + npc.style.position = "absolute"; + npc.style.left = "150px"; npc.style.top = "44px"; + npc.style.zIndex = "3"; + if (npcState === "grief") npc.style.filter = "saturate(.55) brightness(.85)"; + seats.appendChild(npc); + const tagEl = window.BDSChibi.tag( + presentingNpc.toUpperCase(), colorVar, { top: 0 }); + tagEl.style.left = "190px"; tagEl.style.top = "134px"; + tagEl.style.transform = "none"; tagEl.style.zIndex = "3"; + seats.appendChild(tagEl); + + // kevin through the glass (unless kevin is presenting — then derek watches) + const watcher = presentingNpc === "kevin" ? "derek" : "kevin"; + const kv = window.BDSChibi.topSprite({ who: watcher, pose: "lean" }); + kv.style.opacity = ".55"; + room.querySelector("#kevin-glass").appendChild(kv); + + return { + setSlide(kind) { + room.querySelector("#projector").innerHTML = slideHtml(kind); + }, + }; +} + +export function exitBoardroom() { + G.els.boardroom.classList.add("hidden"); + G.els.boardroom.innerHTML = ""; +} diff --git a/static/js/chibi.js b/static/js/chibi.js new file mode 100644 index 0000000000000000000000000000000000000000..69b9dcb19649a3c59dacdbe0501551fb9a730575 --- /dev/null +++ b/static/js/chibi.js @@ -0,0 +1,526 @@ +/* ============================================================ + chibi.js — shared sprite engine for Brad Did Something + Ported from "Character Sprite Sheet.html" v1.0 and extended + with v2 emotional/talk poses, top-down floor sprites and a + box-shadow pixel-glyph drawer (hearts, stars, drops, Z). + Vanilla JS. Load with