Guidance for working in this repo. Day One is a phone-first reentry simulator —
an installable, offline React PWA where you live the first ~90 days after release,
one weekly turn at a time. Read docs/DESIGN.md (the source of truth) before
non-trivial changes; docs/ABOUT.md is the plain-language overview.
npm run dev # compile+validate content, then Vite dev server (:5173)
npm run build # tsc -b + content build + production PWA build → dist/
npm run preview # serve the production build (:4173) — needed to test PWA install/offline
npm test # Vitest: engine + per-sprint + end-to-end playthrough tests
npm run typecheck # tsc -b (regenerates the corpus first via pretypecheck)
npm run build:content # just the YAML→JSON content pipeline
npm run icons # regenerate the procedural sunrise PWA iconsThe service worker is off in dev — use build && preview to test install/offline.
src/content/corpus.generated.json is generated (gitignored); dev/build/test/
typecheck all regenerate it. Editing content/, schema/, or the flag registry in
dev recompiles + reloads.
content (data) → engine (rules) → UI (React). The engine and content know nothing
about React.
src/engine/— pure functions over plain, JSON-safe state. No React, no I/O, no globals.engine.ts(turn loop, choice resolution, effects, scheduling, obligations, the monthly economy tick, serialization),chargen.ts(origin → opening state),randomchar.ts(deterministic random origins),predicate.ts(safe evaluator),rng.ts(seedable mulberry32),debrief.ts(ending profile + trajectory + decision-quality + mode framing),tuning.ts(balance knobs),flags.{ts,json}(flag registry),types.ts(canonical contracts — keep authoritative).index.tsis the public barrel the UI imports from.content/— events + character origins as YAML.scripts/compile-content.mjsvalidates each event againstschema/event.schema.json(AJV) and fails the build on invalid content or an unknown flag. Compiles to the corpus the app imports and the SW precaches.src/ui/+App.tsx— mobile-first, single-column, accessible React shell. Renders state, collects taps. Dexie (db.ts) autosaves/resumes a run;theme.tsdoes light/dark + accent;scenario.tsdoes thecharacter.mode.seedclassroom codes;runShare.tsdoes full run export.
- Turn loop (
engine.ts):beginTurnreplenishes slots (base 7 − standing commitments), fires scheduled incidents intopending, applies weekly flows (wage whilehas_job; a few-week unemployment bridge after a layoff; the weekly home-detention fee), and runs the monthly economy tick (turns 4/8/12): benefits stipend in, and the drains out — transit pass, phone plan (both drop the asset if unpaid), probation supervision fees, and child support for a reunifying parent.endTurnenforces missed obligations, fires edge-triggered pool-floor crises (money/health/morale viaCRISIS_TRIGGERS), snapshots pools, and handles the gated terminal chain. A missed obligation schedules a violation event chosen by supervision type (violationEventFor: parole, probation, or home detention). - Scoring (
debrief.ts): ending profile + milestones + trajectory (pool-vitality momentum) + decision quality (durable vs desperate, from per-choicequalitytags recorded in the log) + mode-aware framing. - Reproducibility: a run = character + mode + seed.
createRunFromOriginchargens any origin (corpus orrandomOrigin(seed)); same inputs ⇒ identical run.
- Engine purity. Nothing in
src/engine/may import React or do I/O. Mutating functions clone (structuredClone) and return new state. - Determinism + serialization. All randomness flows through
state.rngStateviarng.next.serializeRun/loadRunmust round-trip. NewGameStatefields must be JSON-safe and defaulted inloadRun(save migration). - Barriers are data. Every obstacle is a
requireson a choice or aconditionon an event — never hardcoded in the engine or UI. The catch-22 is a visible, disabled button. - No "you lost" screen. Setbacks are crises with branches; the run is scored on
trajectory and decisions, not just final position. Terminal endings are rare,
gated (accumulated violations +
hardFail), and handed to the debrief. - Two audiences, one engine. Training vs empathy differ only in onboarding,
difficulty defaults, debrief framing, and the
hardFailflag — never in core rules.
- Flags live in
src/engine/flags.json(the single registry). Referencing a flag in content that isn't registered fails the content build. Exception:evt_<id>_unlockedflags are generated by an outcome'sunlocks. Add new flags there before using them. - Predicate grammar (
conditions/requires): single comparisons, AND-ed, no boolean operators, noeval. Paths:flags.X | pools.X | tracks.X.status | tracks.X.readiness | turn | mode | config.X. Unknown flags readfalse. - Effects: pool deltas clamp to [0,100]; track
statusis set absolutely,readinessis a clamped delta (exception: housingreadinessis kept in sync with the status as the ladder rank —HOUSING_RANKintuning.ts— so housing rungs gate ontracks.housing.readiness < Nand only move up);unlockssets<id>_unlocked;schedulefires a future incident;flagsset booleans. - Tuning numbers go in
src/engine/tuning.ts, not scattered in logic. - Tests are colocated in
src/engine/*.test.ts, one file per sprint plus the end-to-endplaythrough.test.ts. Add tests for new engine behavior and content gating. Note:buildDebriefis exported from./debrief(and the./indexbarrel), not from./engine.ts— import it accordingly in tests.
- An event: copy an existing file in
content/events/, edit prose/choices/ effects, keep theidmatching the filename (evt_[a-z0-9_]+). Register any new flags. Runnpm run build:content— it fails loudly on mistakes. Events self-gate viaconditions; markrepeatable: truefor things revisitable across weeks (the engine also limits each event to once per turn). - A character: add an origin YAML in
content/characters/, then add its id toARCHETYPE_ORDERinsrc/content/corpus.ts.chargenmaps origins to state generically (pools, tracks, flags, schedule) — author difficulty in the origin data, not in code. Several barrier flags are derived by chargen, not authored:registry_required(fromoffense.registry_required),tech_gap(long time inside ≥TECH_GAP_YEARS),chronic_mental_health(fromperson.mental_health_issue),reunifying(fromperson.reunifying),owes_supervision_fees(from asupervision_feescondition),owes_home_detention_fees(from ahome_detentioncondition), andhas_phone/owes_phone_plan+has_laptop(fromlanding.has_phone/landing.has_laptop). Standing slot taxes come from supervision conditions (mandated_treatment,community_service,home_detention). - Difficulty/mechanics knobs (slot tax, transport multiplier, crisis floors,
monthly economy amounts, terminal threshold, tech-gap years) live in
src/engine/tuning.ts.
main is protected — no direct pushes. Work on a branch, open a PR, let CI
(.github/workflows/ci.yml: content validation + typecheck + tests + build) go
green, then merge. Self-merge is allowed (0 required approvals). See
CONTRIBUTING.md.
Production is live on DigitalOcean App Platform (static site) from main
with deploy_on_push on, so merging a PR to main auto-builds and redeploys.
Spec: .do/app.yaml. The app is a static PWA, so it also self-hosts
behind nginx or Docker. Full paths + the DNS gotcha we hit: docs/DEPLOYMENT.md.
- Don't commit
src/content/corpus.generated.json(gitignored; regenerated). - Never push directly to
main— it's protected, and landing a change there deploys to production. Branch → PR → green CI → merge. Keep it green locally first (npm run typecheck && npm test && npm run build). - Node is via nvm here; if a tool reports "npm not found" in a non-interactive shell it's a PATH/nvm issue, not a repo problem.