feat: bootstrap walking skeleton
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dev",
|
||||||
|
"runtimeExecutable": "pnpm",
|
||||||
|
"runtimeArgs": ["dev", "--port", "5180", "--strictPort"],
|
||||||
|
"port": 5180
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
dev-dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Test / coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
|
.claude/settings.local.json
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Idlegame Agent Guide
|
||||||
|
|
||||||
|
Idlegame is a proprietary idle/incremental text-fantasy RPG inspired by Your
|
||||||
|
Chronicle. It is a responsive web + PWA project with a pure TypeScript game
|
||||||
|
engine and a React/Zustand view layer.
|
||||||
|
|
||||||
|
## User And Context
|
||||||
|
|
||||||
|
- Address the user as **ginnoir** or **senpai**. Never call him Matt.
|
||||||
|
- The Obsidian vault is the cross-project source of truth for design, story,
|
||||||
|
decisions, and session memory.
|
||||||
|
- Vault MCP path: `claude/Context.md` first, then `Idlegame/_Claude.md`.
|
||||||
|
- Local vault path for tools without MCP: `C:\Users\MattC\Documents\Obsidian Vault\`.
|
||||||
|
- Credentials used by AI tools live in the vault at `claude/Credentials.md`.
|
||||||
|
Never hardcode tokens or secrets in this repo.
|
||||||
|
- Repo docs are code-truth: setup, architecture, ADRs, and plans. Vault notes
|
||||||
|
hold design intent and durable operational context.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- Install: `pnpm install`
|
||||||
|
- Dev server: `pnpm dev`
|
||||||
|
- Typecheck: `pnpm typecheck`
|
||||||
|
- Lint/format check: `pnpm lint`
|
||||||
|
- Format: `pnpm format`
|
||||||
|
- Unit tests: `pnpm test`
|
||||||
|
- Coverage: `pnpm test:coverage`
|
||||||
|
- Production build: `pnpm build`
|
||||||
|
|
||||||
|
The required pre-PR verification chain is:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
pnpm test:coverage
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Rules
|
||||||
|
|
||||||
|
- `src/engine/` is pure TypeScript. It must not import React, Zustand, browser
|
||||||
|
APIs, storage APIs, or wall-clock scheduling.
|
||||||
|
- `src/content/` contains data-driven resource/action/story definitions and
|
||||||
|
Zod validation.
|
||||||
|
- `src/state/` bridges engine snapshots into Zustand and owns environment
|
||||||
|
coupling such as requestAnimationFrame, IndexedDB, localStorage fallback, and
|
||||||
|
lifecycle hooks.
|
||||||
|
- `src/ui/` is the React shell. It renders store snapshots and calls runtime
|
||||||
|
commands; gameplay rules stay out of components.
|
||||||
|
- Saves are versioned and validated at the boundary. Invalid or tampered saves
|
||||||
|
must fail explicitly.
|
||||||
|
- Numbers remain human-readable; use the `src/engine/num.ts` boundary before
|
||||||
|
introducing any Decimal-style library.
|
||||||
|
|
||||||
|
## Engineering Norms
|
||||||
|
|
||||||
|
- TypeScript strict, immutable-by-default, explicit errors at boundaries.
|
||||||
|
- Keep functions small and modules focused. Prefer many small files over large
|
||||||
|
catch-all modules.
|
||||||
|
- Tests are required for new engine behavior. Maintain at least 80% coverage on
|
||||||
|
`src/engine/`.
|
||||||
|
- Use permissive dependencies only. No GPL dependencies; this game is
|
||||||
|
proprietary and may be sold.
|
||||||
|
- Commit messages use Conventional Commits, with no AI attribution.
|
||||||
|
|
||||||
|
## Tool Routing
|
||||||
|
|
||||||
|
- Claude Code: architecture, engine-system design, story/design writing, review,
|
||||||
|
vault stewardship, homelab/infra work.
|
||||||
|
- Codex: scoped implementation tickets, debugging, coverage work, mechanical
|
||||||
|
refactors.
|
||||||
|
- Antigravity: UI implementation, responsive polish, browser/playtest
|
||||||
|
verification, visual checks.
|
||||||
|
- Cursor: small ginnoir-driven edits and exploratory pair-programming.
|
||||||
|
|
||||||
|
Use one tool per issue or branch at a time. After M0, all code changes should
|
||||||
|
land through a PR unless ginnoir explicitly says otherwise.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
- Default Git host: private Gitea at `gitea.ginnoir.com/ginnoir/idlegame`.
|
||||||
|
- Trunk is `main`; feature work happens on branches with PRs.
|
||||||
|
- Gitea Issues are implementation work items. Plane epics are roadmap grouping.
|
||||||
|
- Internal playtest URL: `idlegame.ginnoir.com` via Caddy `internal_only`.
|
||||||
|
|
||||||
|
When a session produces durable knowledge, write it back to
|
||||||
|
`Idlegame/_Claude.md` or a topic note in the vault before ending.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@AGENTS.md
|
||||||
|
|
||||||
|
# Claude-Specific Notes
|
||||||
|
|
||||||
|
- Use the Superpowers workflow for feature work: brainstorm when shaping
|
||||||
|
behavior, write/execute plans for multi-step work, and use TDD for engine
|
||||||
|
changes.
|
||||||
|
- Start each session by reading `claude/Context.md` and `Idlegame/_Claude.md`
|
||||||
|
from the Obsidian vault.
|
||||||
|
- Keep design/story decisions in the vault, not in repo-only notes.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Proprietary License Notice
|
||||||
|
|
||||||
|
Copyright (c) 2026 ginnoir. All rights reserved.
|
||||||
|
|
||||||
|
This repository and its contents are proprietary. No license is granted to copy,
|
||||||
|
modify, distribute, sublicense, or use the code or assets except with explicit
|
||||||
|
written permission from the copyright holder.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Idlegame
|
||||||
|
|
||||||
|
Idle/incremental text-fantasy RPG, built as a responsive web app and PWA.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- TypeScript strict
|
||||||
|
- Vite + React 19
|
||||||
|
- Zustand for view state
|
||||||
|
- Pure TypeScript engine under `src/engine`
|
||||||
|
- Zod-validated content and saves
|
||||||
|
- Biome, Vitest, Tailwind v4, vite-plugin-pwa
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Before pushing a branch:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
pnpm test:coverage
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current Scope
|
||||||
|
|
||||||
|
M0 is a walking skeleton: one resource, one timed action, fixed-timestep engine,
|
||||||
|
versioned saves, offline credit, persistence, and a minimal React shell. M1 adds
|
||||||
|
the first playable vertical slice.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- Architecture: `docs/architecture.md`
|
||||||
|
- Tech stack ADR: `docs/adr/0001-tech-stack.md`
|
||||||
|
- M0 plan: `docs/plans/2026-06-11-m0-bootstrap.md`
|
||||||
|
|
||||||
|
Design, story, decisions, credentials, and durable session memory live in the
|
||||||
|
Obsidian vault under `Idlegame/`, not in this repository.
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
|
||||||
|
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
||||||
|
"files": { "ignoreUnknown": true },
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 100
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": { "recommended": true }
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"semicolons": "always",
|
||||||
|
"trailingCommas": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": { "source": { "organizeImports": "on" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# ADR 0001: Web Stack And Engine Boundary
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted, 2026-06-11.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Idlegame is a text-first idle/incremental RPG. The UI is mostly panels, progress
|
||||||
|
bars, action lists, resources, logs, and branching story choices. It must run
|
||||||
|
well on phones from day one and remain easy to wrap later for desktop or mobile
|
||||||
|
stores.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use TypeScript strict, Vite, React 19, Zustand, Tailwind v4, Vitest, Biome, and
|
||||||
|
vite-plugin-pwa.
|
||||||
|
|
||||||
|
Keep gameplay rules in a pure TypeScript engine under `src/engine/`. React,
|
||||||
|
Zustand, browser storage, requestAnimationFrame, and lifecycle hooks belong
|
||||||
|
outside the engine.
|
||||||
|
|
||||||
|
Use plain JavaScript numbers behind `src/engine/num.ts` for M0/M1. Human-readable
|
||||||
|
values are a design pillar; a Decimal-style dependency is deferred until a later
|
||||||
|
layer proves it is needed.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Engine behavior is headless-testable with Vitest.
|
||||||
|
- UI and wrapper changes should not force gameplay rewrites.
|
||||||
|
- Offline progress uses the same fixed-timestep simulation path as online play.
|
||||||
|
- Save/load validation sits at explicit boundaries with versioned schemas.
|
||||||
|
- Dependencies must be permissive; no GPL packages are allowed.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
Idlegame is split into four layers.
|
||||||
|
|
||||||
|
## Engine
|
||||||
|
|
||||||
|
`src/engine/` contains deterministic gameplay logic:
|
||||||
|
|
||||||
|
- `tickLoop.ts`: fixed-timestep accumulator. Large elapsed deltas, including
|
||||||
|
offline catch-up, drain through the same tick path as active play.
|
||||||
|
- `game.ts`: core game state, active action progress, and resource accrual.
|
||||||
|
- `save.ts`: versioned save schema, serialized export/import strings, and
|
||||||
|
offline elapsed calculation.
|
||||||
|
- `num.ts`: branded numeric boundary and human-readable formatting.
|
||||||
|
|
||||||
|
The engine must stay pure. It does not import React, Zustand, browser APIs,
|
||||||
|
storage, requestAnimationFrame, or Date scheduling.
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
`src/content/` contains authored definitions validated by Zod. M0 includes one
|
||||||
|
resource and one timed action. M1 expands this into real resources, actions,
|
||||||
|
story nodes, automation unlocks, and prestige definitions.
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
`src/state/` owns environment coupling:
|
||||||
|
|
||||||
|
- persistence backend selection: IndexedDB via `idb-keyval`, with localStorage
|
||||||
|
fallback
|
||||||
|
- load-on-boot and autosave orchestration
|
||||||
|
- requestAnimationFrame loop and lifecycle hooks
|
||||||
|
- mapping engine state to view models
|
||||||
|
- Zustand store updates for React
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
`src/ui/` renders the view model and calls runtime commands. Components should
|
||||||
|
not implement gameplay rules. The M0 shell includes a resource readout, action
|
||||||
|
panel, progress bar, and event log.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run the full local gate before pushing:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
pnpm test:coverage
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Rendered checks for the walking skeleton:
|
||||||
|
|
||||||
|
- app loads without framework overlay or console errors
|
||||||
|
- starting the timed action increments Gold after completion
|
||||||
|
- reload preserves saved state
|
||||||
|
- reopening after time away credits offline progress
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Idlegame — M0 Bootstrap Plan (handoff artifact for superpowers)
|
||||||
|
|
||||||
|
> **How to use this file:** the interview/brainstorm phase is complete; everything below is locked. Hand this plan to superpowers for execution (this copy in the working dir at `docs/plans/2026-06-11-m0-bootstrap.md` is canonical). Tasks are bite-sized with per-task verification so `executing-plans` can run them sequentially. Feature work after M0 flows through superpowers' brainstorm → plan → TDD cycle per feature.
|
||||||
|
>
|
||||||
|
> **Refined 2026-06-11 (same-day review pass, approved by ginnoir):** T3 split into T3.1–T3.4; T8 deploy auth + Caddy mechanics specified (romhacks-wiki precedent); Node policy `engines >=24` / CI on 24 LTS; pnpm 10 postinstall allowlist note; CI-required protection toggle moved from T6 to T7. No design decisions re-opened. Review-gate addition: per-task tool + model routing for M0 (§AI tool routing).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ginnoir is starting a commercial-grade idle/incremental game: a text fantasy RPG inspired by **Your Chronicle** (timed actions, resource generate/consume loops, story objectives), structured like a visual novel with branching choices, ending runs in a prestige system that avoids same-y repeats. Monetization, if ever: upfront purchase or cosmetics only — QoL and speed-up are never paywalled; any process completed once becomes automatable.
|
||||||
|
|
||||||
|
**M0 scope**: capture the locked design in the vault, stand up the multi-tool workflow (Gitea + CI + internal playtest deploys + Plane + Obsidian), and scaffold a walking-skeleton codebase. Gameplay (M1 vertical slice) is seeded as Gitea issues and built in later sessions.
|
||||||
|
|
||||||
|
Starting state: `C:\Users\MattC\Documents\idlegame` is empty except `docs/plans/`, not a git repo. Gitea runs at `gitea.ginnoir.com` (owner `ginnoir`, private default, HTTPS creds in GCM). No Gitea Actions runner exists yet. Plane is self-hosted on valhalla. Obsidian vault is the cross-project source of truth. Superpowers drives development workflow; all four AI tools (Claude Code, Codex, Antigravity, Cursor) get Obsidian MCP + local vault access.
|
||||||
|
|
||||||
|
## Locked design pillars (interview, 2026-06-11)
|
||||||
|
|
||||||
|
| Pillar | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Platform | Responsive web + PWA; plays well on phones from day one. Steam (Tauri/Electron) and app-store (Capacitor) wraps are later milestones |
|
||||||
|
| Visuals | Text-first UI: panels, progress bars, action lists, event log. No art pipeline |
|
||||||
|
| Combat | Light/abstracted — timed actions with stat checks and risk/reward; no battle screen in v1 |
|
||||||
|
| First playable (M1) | Vertical slice: one short story arc → first prestige reset; branching choice; automation unlock; offline progress; save/load |
|
||||||
|
| Prestige | **Hybrid**: aggressive catch-up *within* a layer (Magic Research style — keep knowledge, blast through known content); genuinely new mechanics *between* layers (Antimatter Dimensions style) |
|
||||||
|
| Numbers | Human-readable throughout (≤ billions). No scientific-notation spectacle |
|
||||||
|
| Pacing | Semi-active (Your Chronicle-like): queue actions, choices every few minutes; idles/offline fine |
|
||||||
|
| Story authoring | Outline together in Obsidian; Claude drafts prose; ginnoir reviews/edits |
|
||||||
|
| Automation principle | Complete a process once → it becomes automatable (generous, progression-gated) |
|
||||||
|
| CI/CD | Gitea Actions runner on valhalla; typecheck/lint/test/build on push; auto-deploy dev build to internal-only playtest URL |
|
||||||
|
| Tracking | Plane = roadmap/epics. Gitea Issues = code-level tasks (commit/PR auto-linking) |
|
||||||
|
| Codename | `idlegame` (rename later is cheap in Gitea) |
|
||||||
|
|
||||||
|
## Tech stack (researched recommendation)
|
||||||
|
|
||||||
|
**TypeScript (strict) + Vite + React 19 + Zustand, with the game engine as a pure-TS module that never imports React.**
|
||||||
|
|
||||||
|
- Genre precedent: every successful game in this genre is a web app — Antimatter Dimensions (Vue), Magic Research (React), Melvor Idle (web, wrapped for Steam/mobile). Text/UI-heavy idle games are DOM games; Godot/Unity fight you on UI and bloat web exports.
|
||||||
|
- React over Svelte/Vue: deepest fluency across all four AI tools — important when agents write most of the code. Perf is a non-issue with the core/view split: engine ticks on its own loop; UI subscribes to snapshots at ~10 fps via Zustand selectors.
|
||||||
|
- The engine/view split is the load-bearing decision: headless-testable game core (Vitest, no DOM); Tauri/Capacitor wraps or even a framework swap later touch only the view.
|
||||||
|
- **Considered & rejected**: Profectus (MIT, Vue, Modding-Tree lineage, ~34 stars — license fine but Vue-locked, shaped for tree-prestige games, community too small for a commercial bet); The Modding Tree (older JS, same shape); Godot (canvas UI friction for a text game).
|
||||||
|
|
||||||
|
Dependencies (all permissive; the game stays proprietary — no GPL anywhere):
|
||||||
|
|
||||||
|
| Dep | License | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| react, zustand | MIT | view + state bridge |
|
||||||
|
| zod | MIT | schema validation for content definitions and saves |
|
||||||
|
| lz-string | MIT | compressed save export/import strings |
|
||||||
|
| idb-keyval | Apache-2.0 | IndexedDB persistence (localStorage fallback) |
|
||||||
|
| vite, vite-plugin-pwa, workbox | MIT | build + installable PWA |
|
||||||
|
| tailwindcss v4 | MIT | styling for the text-first UI |
|
||||||
|
| biome, vitest, (playwright later) | MIT/Apache-2.0 | lint+format, unit tests, E2E later |
|
||||||
|
|
||||||
|
Toolchain: **pnpm**, **Node 24 LTS as the CI/runtime baseline** — `engines: { "node": ">=24" }` (a floor, not an exact pin: local boxes may run newer — Node 25.9 verified working 2026-06-11; a strict `24.x` pin makes every local `pnpm install` warn), `.nvmrc` = `24`, and CI jobs run a `node:24` image so the LTS pin is enforced where determinism matters. Numbers: plain JS numbers behind `src/engine/num.ts` (format + branded type); ADR documents the Decimal escape hatch if a late prestige layer ever needs it.
|
||||||
|
|
||||||
|
## Workflow architecture
|
||||||
|
|
||||||
|
- **Repo**: `gitea.ginnoir.com/ginnoir/idlegame`, private. Trunk `main` (protected, CI required), feature branches + PRs, conventional commits, self-merge allowed (solo + AI dev).
|
||||||
|
- **Issues**: Gitea labels (`type/feat|fix|content|infra`, `area/engine|ui|story|workflow`, `prio/1-3`), milestones `M0 Scaffold`, `M1 Vertical Slice`. Issue templates (feature/bug/content) + PR template in `.gitea/`.
|
||||||
|
- **Plane**: project "Idlegame" with epics (Vertical Slice, Workflow & Infra, Game Design). Epics link Gitea issues by URL; day-to-day work lives in Gitea.
|
||||||
|
- **CI** (`.gitea/workflows/ci.yml`): pnpm install → `typecheck` → `biome ci` → `vitest run --coverage` → `build` → upload `dist` artifact.
|
||||||
|
- **Deploy** (on push to `main`): rsync `dist/` over SSH (dedicated deploy key stored as repo Actions secrets) to valhalla's Caddy site dir `/config/caddy/site/idlegame/` → `idlegame.ginnoir.com`, **internal-only during development** (Caddy `import internal_only` — same pattern as other internal sites; no auth layer needed). Precedent: romhack-archive-site static deploy (`/config/caddy/site/romhacks-wiki`).
|
||||||
|
- **Runner**: `act_runner` container (docker mode, outbound polling only) added to homelabstack's `dev` stack, following that repo's conventions — read `Homelab/_Claude.md` + `Deploy.md` in the vault before touching homelabstack (applies to T7 **and** T8).
|
||||||
|
|
||||||
|
## Cross-tool agent layer
|
||||||
|
|
||||||
|
- **`AGENTS.md` is canonical** — Linux Foundation standard; Codex, Cursor, and Antigravity read it natively. Contents: project overview, commands, architecture map, conventions (commits, PR flow, engine/view boundary rules), the context-layer contract, and the tool-routing table below.
|
||||||
|
- **`CLAUDE.md`** = `@AGENTS.md` import + Claude-only extras (superpowers notes, vault skill pointers).
|
||||||
|
- **Context-layer contract** (stated in AGENTS.md): Obsidian vault (all tools have MCP + the local folder `C:\Users\MattC\Documents\Obsidian Vault\`; record both in AGENTS.md) = design docs, story, decisions, session memory. **API tokens tools authenticate with live in the vault at `claude/Credentials.md`** — never in the repo. Repo `docs/` = code-truth (README, ADRs, architecture, superpowers plans in `docs/plans/`). Gitea Issues = work items. Every tool writes session outcomes back to the vault.
|
||||||
|
|
||||||
|
## AI tool routing (light)
|
||||||
|
|
||||||
|
One tool per issue/branch at a time; everything lands via PR; AGENTS.md + vault are the shared brain regardless of tool.
|
||||||
|
|
||||||
|
| Tool | Best at | Route these tasks |
|
||||||
|
|---|---|---|
|
||||||
|
| **Claude Code** (+ superpowers) | Architecture, multi-step planning, nuanced writing, big multi-file features, code review | Engine/systems design, prestige & balance design, story outlining + prose drafts, milestone planning, reviewing other tools' PRs, vault stewardship |
|
||||||
|
| **Codex** | Long autonomous runs on well-scoped tasks, gnarly debugging, test discipline | Well-scoped implementation tickets, bug hunts with repro steps, test-coverage passes, mechanical refactors |
|
||||||
|
| **Antigravity** | Frontend work with built-in browser verification, multimodal/screenshot feedback, large-context research | UI implementation + polish, responsive/mobile layout passes, visual verification of the playtest build, competitive research (other idle games) |
|
||||||
|
| **Cursor** | Interactive pair-programming, fast small edits while ginnoir drives; agent mode now frontier-class (Composer 2.5 ≈ Opus 4.8 on benchmarks, June 2026) | Quick fixes, small scoped tweaks, exploratory fiddling, anything ginnoir wants to steer by hand; credible alternate for Codex-lane scoped tickets |
|
||||||
|
|
||||||
|
Rule of thumb: design and words → Claude; heads-down scoped code → Codex; anything you need to *see* → Antigravity; anything you're driving yourself → Cursor.
|
||||||
|
|
||||||
|
### Per-task routing (M0)
|
||||||
|
|
||||||
|
Applies the rules above to T1–T10. Claude Code legitimately dominates M0 — it's workflow/infra/writing-heavy by design; Codex and Antigravity take the tasks where they genuinely win. **Cursor has no M0 assignment** (its lane is ginnoir-driven interactive edits; M0 runs autonomously — Cursor's turn starts at M1). **Default execution mode is unchanged**: a single superpowers/Claude Code `executing-plans` run per the header; the table says who's *best* per task — route T2/T3.2/T3.3/T3.4 out to Codex/Antigravity only if ginnoir wants to shake down the multi-tool workflow during M0. For M1+ feature work the table's logic is the standing template.
|
||||||
|
|
||||||
|
**Claude Code primary is Opus 4.8** — Fable 5 access ends **2026-06-22**, so nothing durable relies on it. ⭐F marks the tasks that benefit most from Fable 5 while it lasts (the highest-judgment writing and architecture calls); if M0 executes before the cutoff, running the whole thing on Fable 5 is fine.
|
||||||
|
|
||||||
|
| Task | Tool | Model | Why |
|
||||||
|
|---|---|---|---|
|
||||||
|
| T1 vault notes | Claude Code | Opus 4.8 ⭐F | Vault stewardship; GDD/story prose quality compounds downstream |
|
||||||
|
| T2 scaffold | Codex | GPT-5.5-Codex (medium) | Well-scoped ticket, command-line verification, gotchas pre-spelled-out; alt: Cursor agent (Composer 2.5) |
|
||||||
|
| T3.1 num + tick loop | Claude Code | Opus 4.8 ⭐F | Load-bearing engine architecture: API shape + TDD, not heads-down typing |
|
||||||
|
| T3.2 resource + action | Codex | GPT-5.5-Codex (medium) | Scoped code on shapes T3.1 fixed; fully test-gated |
|
||||||
|
| T3.3 saves + persistence | Codex | GPT-5.5-Codex (highest effort tier) | Save integrity = the data-loss surface; strongest test discipline |
|
||||||
|
| T3.4 Zustand + React shell | Antigravity | Gemini 3 Pro (high thinking) | Browser verification watches the progress bar, reload, offline credit |
|
||||||
|
| T4 AGENTS.md + docs | Claude Code | Opus 4.8 ⭐F | The conventions doc every other tool reads |
|
||||||
|
| T5 git + Gitea repo | Claude Code | Sonnet 4.6¹ | API glue with vault-held creds; mechanical |
|
||||||
|
| T6 repo config | Claude Code | Sonnet 4.6¹ | Labels/protection/templates via API |
|
||||||
|
| T7 Actions runner | Claude Code | Opus 4.8 | Touches the live homelab stack; vault conventions + care |
|
||||||
|
| T8 playtest deploy | Claude Code | Opus 4.8 | Secrets, Caddy, cross-repo infra; Antigravity optional for browser/PWA verify-assist |
|
||||||
|
| T9 Plane + M1 seeding | Claude Code | Opus 4.8 ⭐F | Issue prose = the contract for M1 sessions |
|
||||||
|
| T10 write-back | Claude Code | Sonnet 4.6¹ | Vault stewardship, mechanical |
|
||||||
|
|
||||||
|
¹ Tier note: if M0 runs as one continuous `executing-plans` session (lowest-friction path), stay on one model throughout — the Sonnet 4.6 entries mean "this tier suffices when run standalone / cost-sensitive."
|
||||||
|
|
||||||
|
Caveats: (a) all of T3 predates the repo (git init is T5), so M0 handoffs to Codex/Antigravity land directly in the working dir without the PR gate — hand off only at task boundaries with green tests; full one-tool-per-branch discipline starts at M1. (b) Model names current as of 2026-06-11 (Opus 4.8 / Sonnet 4.6 standing, Fable 5 only until 2026-06-22; GPT-5.5-Codex; Gemini 3 Pro; Cursor Composer 2.5, which benchmarks ≈ Opus 4.8 — making it Cursor's default for everything and Cursor agent a credible Codex-lane alternate at M1+) — substitute each tool's newest equivalent tier at execution time.
|
||||||
|
|
||||||
|
## Obsidian vault additions
|
||||||
|
|
||||||
|
Per `claude/Vault.md` convention: `Idlegame/_Claude.md` (what it is, working dir, repo URL, standing rules — monetization stance, automation principle, prestige philosophy — quick-nav, session log, `[[Context]]` backlink); `Idlegame/GDD.md` (pillars + core loops from the interview); `Idlegame/Story/Outline.md` (authoring-workflow skeleton); `Idlegame/Decisions.md` (design ADR-lite; code ADRs live in repo). Add the row to `claude/Context.md`'s project table.
|
||||||
|
|
||||||
|
## Scaffold layout
|
||||||
|
|
||||||
|
```
|
||||||
|
idlegame/
|
||||||
|
AGENTS.md CLAUDE.md README.md LICENSE.md (proprietary notice)
|
||||||
|
.gitea/workflows/{ci,deploy}.yml .gitea/issue_template/ PR template
|
||||||
|
.nvmrc package.json tsconfig.json (strict) biome.json vite.config.ts (PWA)
|
||||||
|
docs/adr/0001-tech-stack.md docs/architecture.md docs/plans/ (superpowers plans)
|
||||||
|
src/engine/ ← pure TS: fixed-timestep tick loop + offline catch-up, resources,
|
||||||
|
action queue, story graph, prestige hooks, versioned saves
|
||||||
|
(zod-validated, lz-string export), num.ts
|
||||||
|
src/content/ ← data-driven, zod-validated definitions (resources, actions, story nodes)
|
||||||
|
src/state/ ← zustand bridge: engine snapshots → view
|
||||||
|
src/ui/ ← React shell: action panel, resource bar, event log, settings
|
||||||
|
src/engine/__tests__/ ← engine core target: 80%+ coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
The scaffold ships a **walking skeleton**, not gameplay: 1 resource, 1 timed action, tick loop with offline catch-up, save → reload → intact. Proves the architecture end to end; M1 makes it a game.
|
||||||
|
|
||||||
|
## Tasks (superpowers-executable, in order)
|
||||||
|
|
||||||
|
**T1 — Vault project setup.** `Idlegame/_Claude.md` and the `claude/Context.md` row already exist (created at planning handoff) — extend `_Claude.md`, don't recreate. Create `Idlegame/GDD.md`, `Idlegame/Story/Outline.md`, `Idlegame/Decisions.md` (content from this plan). *Verify: vault_read returns each note; Context.md table has the row.*
|
||||||
|
|
||||||
|
**T2 — Scaffold the app.** Vite react-ts template → strict tsconfig, Biome, Vitest, Tailwind, vite-plugin-pwa, folder layout above (except `.gitea/workflows/deploy.yml` — deferred to T8 so deploy never runs red before its secrets exist), `.nvmrc` (`24`) + `engines: { "node": ">=24" }`. pnpm 10 blocks dependency postinstall scripts by default — allowlist build deps (esbuild etc.) via `pnpm.onlyBuiltDependencies` in `package.json` (or `pnpm approve-builds`), or the toolchain silently breaks. *Verify: `pnpm typecheck && pnpm lint && pnpm test && pnpm build` all green; `pnpm dev` serves; manifest in build output.*
|
||||||
|
|
||||||
|
**T3 — Walking skeleton (TDD, four sub-tasks).** Tests first throughout; engine code never imports React.
|
||||||
|
|
||||||
|
- **T3.1 — Engine core: `num.ts` + tick loop.** Branded number type + human-readable formatting; fixed-timestep accumulator loop (`advance(now)`) where offline catch-up is the same code path (large delta → many ticks, batch-capped). Configure Vitest coverage thresholds here (≥80% on `src/engine/`). *Verify: tests green; determinism test — N elapsed seconds always yields the same tick count.*
|
||||||
|
- **T3.2 — One resource, one timed action.** Zod-validated content definitions in `src/content/`: one resource, one timed action (duration → yields resource); action progress advances per tick, completion grants the yield. *Verify: accrual + completion unit tests green.*
|
||||||
|
- **T3.3 — Versioned saves + persistence.** Zod save schema with version field, serialize/deserialize, lz-string export/import string, idb-keyval persistence (localStorage fallback), autosave + load-on-boot, offline elapsed credited on load. *Verify: round-trip test; invalid/tampered save rejected cleanly; offline-credit unit test.*
|
||||||
|
- **T3.4 — Zustand bridge + React shell.** Engine snapshots → Zustand store (~10 fps); minimal UI: resource readout, start-action button with progress bar, event log stub. *Verify: engine files ≥80% coverage overall; manual — action completes, resource increments, reload preserves state, offline time credited.*
|
||||||
|
|
||||||
|
**T4 — Agent layer + docs.** AGENTS.md (incl. routing table + context contract + vault local path), CLAUDE.md import, README, ADR-0001 (stack), `docs/architecture.md`; this plan already lives at `docs/plans/2026-06-11-m0-bootstrap.md` — keep it current. *Verify: files exist; AGENTS.md states commands that actually run.*
|
||||||
|
|
||||||
|
**T5 — Git + Gitea repo.** `git init -b main` (git 2.46 may default to `master`), conventional commits, create private repo via Gitea API, push `main`. *Verify: repo visible at gitea.ginnoir.com/ginnoir/idlegame with full tree.*
|
||||||
|
|
||||||
|
**T6 — Repo config.** Labels, milestones M0/M1, issue/PR templates live, protect `main` (no direct pushes, PRs required, self-merge ok). The **CI-required** status check is deliberately deferred to T7 — flipping it on before a runner exists would leave `main` unmergeable. *Verify: direct push to main rejected; templates render on new-issue page.*
|
||||||
|
|
||||||
|
**T7 — Actions runner.** Read `Homelab/_Claude.md` + `Deploy.md` first. Add `act_runner` (docker mode) to homelabstack `dev` stack per that repo's conventions; register with token; enable Actions on the repo. After the first green run, flip **CI-required** on `main`'s protection (deferred from T6). *Verify: a test branch push runs CI to green on the runner (`main` rejects direct pushes by now); merging to main now requires green CI.*
|
||||||
|
|
||||||
|
**T8 — Playtest deploy.** Read `Homelab/_Claude.md` + `Deploy.md` first (same rule as T7). Auth: generate a dedicated ed25519 deploy keypair; append the pubkey to the `ginnoir` user's `authorized_keys` on valhalla; store private key + `known_hosts` as repo Actions secrets via the Gitea API. Create `.gitea/workflows/deploy.yml` here (deferred from T2): on push to `main`, build → rsync `dist/` over SSH to `valhalla:/config/caddy/site/idlegame/` (mirrors the romhacks-wiki precedent). Caddy block in homelabstack: `idlegame.ginnoir.com` with `import internal_only`, `file_server`, SPA `try_files` fallback to `/index.html` (PWA routing); lands via the existing Caddyfile push workflow. *Verify: DNS resolves on LAN (add a host entry per homelab conventions if no wildcard covers it); URL loads the walking skeleton on desktop and phone (LAN); PWA installable.*
|
||||||
|
|
||||||
|
**T9 — Plane + M1 seeding.** *(Caddy bypass done 2026-06-11, commit `9935041` in homelabstack — `/api/*` + `X-API-Key` now routes directly to `plane_api:8000`, bypassing Authentik; verified.)* Plane project "Idlegame" + 3 epics via API (key in vault `claude/Credentials.md`); ~10 M1 issues in Gitea (engine tick/offline, resource & action definitions, action queue UI, story graph + first branch, automation unlock, prestige reset, save migrations, mobile layout pass, opening-arc content, balance pass), labeled and milestoned, linked from epics. *Verify: Plane shows epics; Gitea milestone M1 lists the issues.*
|
||||||
|
|
||||||
|
**T10 — Write-back.** Session outcomes → `Idlegame/_Claude.md` session log; mark M0 complete in Gitea. *Verify: vault note updated; M0 milestone closed.*
|
||||||
|
|
||||||
|
No task needs to ask ginnoir for secrets or approval: the Gitea admin API token and the Plane API key live in the vault at `claude/Credentials.md` (local file: `C:\Users\MattC\Documents\Obsidian Vault\claude\Credentials.md`). Runner registration tokens are minted at deploy time via `POST /api/v1/admin/actions/runners/registration-token` (the GET route 404s on Gitea 1.26.2; CLI fallback: `docker exec gitea gitea actions generate-runner-token` on valhalla). The T8 deploy keypair is generated at execution time and stored via the Gitea secrets API — no secret passes through ginnoir's hands. Verified 2026-06-11: Gitea token has admin scope, Actions enabled, 0 runners registered. Plane API Caddy bypass is live (homelabstack `9935041`, 2026-06-11) — all tasks T1–T10 can run fully autonomously.
|
||||||
|
|
||||||
|
## Out of scope (later milestones)
|
||||||
|
|
||||||
|
M1 gameplay itself; Steam/Tauri and Capacitor wraps; Playwright E2E; release tagging + changelog; i18n; cloud saves; public playtest access.
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<meta name="description" content="Idle/incremental text-fantasy RPG." />
|
||||||
|
<title>Idlegame</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "idlegame",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Idle/incremental text-fantasy RPG (proprietary).",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.33.3",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "biome ci .",
|
||||||
|
"format": "biome format --write .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"idb-keyval": "^6.2.5",
|
||||||
|
"lz-string": "^1.5.0",
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6",
|
||||||
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.16",
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@types/node": "^24.12.3",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.8",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.0.12",
|
||||||
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
|
"vitest": "^4.1.8"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@biomejs/biome",
|
||||||
|
"@tailwindcss/oxide",
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4767
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
# Data-driven, zod-validated content definitions (resources, actions, story nodes).
|
||||||
|
# Populated in T3.2 (resource + action) and M1 (story graph).
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildContent } from '../schema';
|
||||||
|
|
||||||
|
const validResources = [{ id: 'gold', name: 'Gold' }];
|
||||||
|
const validActions = [
|
||||||
|
{ id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('buildContent()', () => {
|
||||||
|
it('validates definitions and indexes them by id', () => {
|
||||||
|
const content = buildContent({ resources: validResources, actions: validActions });
|
||||||
|
expect(content.resourcesById.gold.name).toBe('Gold');
|
||||||
|
expect(content.actionsById.forage.durationMs).toBe(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the startAmount default of 0', () => {
|
||||||
|
const content = buildContent({ resources: validResources, actions: validActions });
|
||||||
|
expect(content.resourcesById.gold.startAmount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an action that yields an unknown resource', () => {
|
||||||
|
const actions = [
|
||||||
|
{
|
||||||
|
id: 'forage',
|
||||||
|
name: 'Forage',
|
||||||
|
durationMs: 3000,
|
||||||
|
yields: { resourceId: 'ghost', amount: 1 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(() => buildContent({ resources: validResources, actions })).toThrow(/unknown resource/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate resource ids', () => {
|
||||||
|
const resources = [
|
||||||
|
{ id: 'gold', name: 'Gold' },
|
||||||
|
{ id: 'gold', name: 'Gold Again' },
|
||||||
|
];
|
||||||
|
expect(() => buildContent({ resources, actions: validActions })).toThrow(/duplicate/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a structurally invalid definition', () => {
|
||||||
|
const actions = [
|
||||||
|
{ id: 'forage', name: 'Forage', durationMs: -1, yields: { resourceId: 'gold', amount: 1 } },
|
||||||
|
];
|
||||||
|
expect(() => buildContent({ resources: validResources, actions })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Walking-skeleton content: one resource, one timed action.
|
||||||
|
*
|
||||||
|
* M1 expands this into the real resource/action set and the story graph. Kept as
|
||||||
|
* plain data so it stays diffable and authorable without touching engine code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const resourceDefs = [{ id: 'gold', name: 'Gold', startAmount: 0 }];
|
||||||
|
|
||||||
|
export const actionDefs = [
|
||||||
|
{
|
||||||
|
id: 'forage',
|
||||||
|
name: 'Forage for coin',
|
||||||
|
durationMs: 3000,
|
||||||
|
yields: { resourceId: 'gold', amount: 1 },
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { actionDefs, resourceDefs } from './definitions';
|
||||||
|
import { buildContent } from './schema';
|
||||||
|
|
||||||
|
/** The validated, indexed content the engine and view consume. */
|
||||||
|
export const content = buildContent({ resources: resourceDefs, actions: actionDefs });
|
||||||
|
|
||||||
|
export type { ActionDef, Content, ResourceDef } from './schema';
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content schemas.
|
||||||
|
*
|
||||||
|
* Game content (resources, actions, later story nodes) is data, validated at
|
||||||
|
* load time with zod so a malformed definition fails loudly instead of corrupting
|
||||||
|
* runtime state. The engine consumes the validated `Content` object and never
|
||||||
|
* reaches for raw definitions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const resourceDefSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
startAmount: z.number().nonnegative().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionDefSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
durationMs: z.number().positive(),
|
||||||
|
yields: z.object({
|
||||||
|
resourceId: z.string().min(1),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResourceDef = z.infer<typeof resourceDefSchema>;
|
||||||
|
export type ActionDef = z.infer<typeof actionDefSchema>;
|
||||||
|
|
||||||
|
export interface Content {
|
||||||
|
resources: ResourceDef[];
|
||||||
|
actions: ActionDef[];
|
||||||
|
resourcesById: Record<string, ResourceDef>;
|
||||||
|
actionsById: Record<string, ActionDef>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexById<T extends { id: string }>(items: T[], kind: string): Record<string, T> {
|
||||||
|
const byId: Record<string, T> = {};
|
||||||
|
for (const item of items) {
|
||||||
|
if (byId[item.id]) {
|
||||||
|
throw new Error(`Duplicate ${kind} id "${item.id}"`);
|
||||||
|
}
|
||||||
|
byId[item.id] = item;
|
||||||
|
}
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate raw definitions and build the indexed, referentially-checked Content. */
|
||||||
|
export function buildContent(input: { resources: unknown[]; actions: unknown[] }): Content {
|
||||||
|
const resources = input.resources.map((r) => resourceDefSchema.parse(r));
|
||||||
|
const actions = input.actions.map((a) => actionDefSchema.parse(a));
|
||||||
|
|
||||||
|
const resourcesById = indexById(resources, 'resource');
|
||||||
|
const actionsById = indexById(actions, 'action');
|
||||||
|
|
||||||
|
for (const action of actions) {
|
||||||
|
if (!resourcesById[action.yields.resourceId]) {
|
||||||
|
throw new Error(
|
||||||
|
`Action "${action.id}" yields unknown resource "${action.yields.resourceId}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resources, actions, resourcesById, actionsById };
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildContent } from '../../content/schema';
|
||||||
|
import { createGameState, startAction, tickGame } from '../game';
|
||||||
|
|
||||||
|
function testContent() {
|
||||||
|
return buildContent({
|
||||||
|
resources: [{ id: 'gold', name: 'Gold', startAmount: 5 }],
|
||||||
|
actions: [
|
||||||
|
{ id: 'forage', name: 'Forage', durationMs: 300, yields: { resourceId: 'gold', amount: 2 } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createGameState()', () => {
|
||||||
|
it('seeds resource amounts from their start amounts with no active action', () => {
|
||||||
|
const state = createGameState(testContent());
|
||||||
|
expect(state.resources.gold).toBe(5);
|
||||||
|
expect(state.activeActionId).toBeNull();
|
||||||
|
expect(state.actionElapsedMs).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('startAction()', () => {
|
||||||
|
it('activates the action and resets its progress', () => {
|
||||||
|
const content = testContent();
|
||||||
|
const state = createGameState(content);
|
||||||
|
state.actionElapsedMs = 999;
|
||||||
|
startAction(state, content, 'forage');
|
||||||
|
expect(state.activeActionId).toBe('forage');
|
||||||
|
expect(state.actionElapsedMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on an unknown action id', () => {
|
||||||
|
const content = testContent();
|
||||||
|
const state = createGameState(content);
|
||||||
|
expect(() => startAction(state, content, 'nope')).toThrow(/unknown action/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tickGame()', () => {
|
||||||
|
it('does nothing when no action is active', () => {
|
||||||
|
const content = testContent();
|
||||||
|
const state = createGameState(content);
|
||||||
|
tickGame(state, content, 100);
|
||||||
|
expect(state.resources.gold).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advances action progress without yielding before completion', () => {
|
||||||
|
const content = testContent();
|
||||||
|
const state = createGameState(content);
|
||||||
|
startAction(state, content, 'forage');
|
||||||
|
tickGame(state, content, 100); // 100 of 300ms
|
||||||
|
expect(state.resources.gold).toBe(5);
|
||||||
|
expect(state.actionElapsedMs).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grants the yield on completion and repeats, carrying the remainder', () => {
|
||||||
|
const content = testContent();
|
||||||
|
const state = createGameState(content);
|
||||||
|
startAction(state, content, 'forage');
|
||||||
|
tickGame(state, content, 100);
|
||||||
|
tickGame(state, content, 100);
|
||||||
|
tickGame(state, content, 100); // 300ms -> one completion, +2 gold
|
||||||
|
expect(state.resources.gold).toBe(7);
|
||||||
|
expect(state.actionElapsedMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple completions within a single large tick (offline catch-up)', () => {
|
||||||
|
const content = testContent();
|
||||||
|
const state = createGameState(content);
|
||||||
|
startAction(state, content, 'forage');
|
||||||
|
tickGame(state, content, 1000); // 3 completions (900ms) + 100ms remainder
|
||||||
|
expect(state.resources.gold).toBe(11); // 5 + 3*2
|
||||||
|
expect(state.actionElapsedMs).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatNum, num } from '../num';
|
||||||
|
|
||||||
|
describe('num()', () => {
|
||||||
|
it('returns the same numeric value, branded', () => {
|
||||||
|
expect(num(42)).toBe(42);
|
||||||
|
expect(num(0)).toBe(0);
|
||||||
|
expect(num(-7.5)).toBe(-7.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-finite values', () => {
|
||||||
|
expect(() => num(Number.NaN)).toThrow(RangeError);
|
||||||
|
expect(() => num(Number.POSITIVE_INFINITY)).toThrow(RangeError);
|
||||||
|
expect(() => num(Number.NEGATIVE_INFINITY)).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatNum()', () => {
|
||||||
|
it('formats values under 1000 without a suffix', () => {
|
||||||
|
expect(formatNum(0)).toBe('0');
|
||||||
|
expect(formatNum(42)).toBe('42');
|
||||||
|
expect(formatNum(999)).toBe('999');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims fractional values to at most two decimals', () => {
|
||||||
|
expect(formatNum(12.5)).toBe('12.5');
|
||||||
|
expect(formatNum(12.3456)).toBe('12.35');
|
||||||
|
expect(formatNum(7.0)).toBe('7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses K / M / B suffixes for larger magnitudes', () => {
|
||||||
|
expect(formatNum(1000)).toBe('1K');
|
||||||
|
expect(formatNum(1234)).toBe('1.23K');
|
||||||
|
expect(formatNum(12_000)).toBe('12K');
|
||||||
|
expect(formatNum(1_500_000)).toBe('1.5M');
|
||||||
|
expect(formatNum(2_000_000_000)).toBe('2B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never uses scientific notation, even past a billion', () => {
|
||||||
|
const s = formatNum(1_500_000_000_000);
|
||||||
|
expect(s).not.toMatch(/e/i);
|
||||||
|
expect(s).toBe('1,500B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves sign for negative values', () => {
|
||||||
|
expect(formatNum(-42)).toBe('-42');
|
||||||
|
expect(formatNum(-1500)).toBe('-1.5K');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildContent } from '../../content/schema';
|
||||||
|
import { createGameState, startAction } from '../game';
|
||||||
|
import {
|
||||||
|
applyOfflineProgress,
|
||||||
|
createSave,
|
||||||
|
deserializeSave,
|
||||||
|
fromExportString,
|
||||||
|
SAVE_VERSION,
|
||||||
|
serializeSave,
|
||||||
|
toExportString,
|
||||||
|
} from '../save';
|
||||||
|
|
||||||
|
function testContent() {
|
||||||
|
return buildContent({
|
||||||
|
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
|
||||||
|
actions: [
|
||||||
|
{ id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleState() {
|
||||||
|
const content = testContent();
|
||||||
|
const state = createGameState(content);
|
||||||
|
state.resources.gold = 12;
|
||||||
|
startAction(state, content, 'forage');
|
||||||
|
state.actionElapsedMs = 500;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createSave()', () => {
|
||||||
|
it('stamps the current version and timestamp around a snapshot of state', () => {
|
||||||
|
const save = createSave(sampleState(), 1700);
|
||||||
|
expect(save.version).toBe(SAVE_VERSION);
|
||||||
|
expect(save.savedAt).toBe(1700);
|
||||||
|
expect(save.state.resources.gold).toBe(12);
|
||||||
|
expect(save.state.activeActionId).toBe('forage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snapshots state so later mutation does not affect the save', () => {
|
||||||
|
const state = sampleState();
|
||||||
|
const save = createSave(state, 1700);
|
||||||
|
state.resources.gold = 999;
|
||||||
|
expect(save.state.resources.gold).toBe(12);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('serialize / deserialize round-trip', () => {
|
||||||
|
it('survives JSON serialization unchanged', () => {
|
||||||
|
const save = createSave(sampleState(), 1700);
|
||||||
|
const restored = deserializeSave(serializeSave(save));
|
||||||
|
expect(restored).toEqual(save);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives the compressed export string unchanged', () => {
|
||||||
|
const save = createSave(sampleState(), 1700);
|
||||||
|
const restored = fromExportString(toExportString(save));
|
||||||
|
expect(restored).toEqual(save);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalid / tampered saves', () => {
|
||||||
|
it('rejects a non-JSON export string cleanly', () => {
|
||||||
|
expect(() => fromExportString('@@@not-a-valid-payload@@@')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects JSON that does not match the save schema', () => {
|
||||||
|
expect(() => deserializeSave('{"version":1,"savedAt":1,"state":{}}')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unsupported save version', () => {
|
||||||
|
const future = JSON.stringify({
|
||||||
|
version: 999,
|
||||||
|
savedAt: 1,
|
||||||
|
state: { resources: {}, activeActionId: null, actionElapsedMs: 0 },
|
||||||
|
});
|
||||||
|
expect(() => deserializeSave(future)).toThrow(/version/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyOfflineProgress()', () => {
|
||||||
|
it('credits whole ticks of elapsed time to the active action', () => {
|
||||||
|
const content = testContent();
|
||||||
|
const state = createGameState(content);
|
||||||
|
startAction(state, content, 'forage'); // 3000ms per gold
|
||||||
|
const credited = applyOfflineProgress(state, content, 1000, 1000 + 9000); // 9s
|
||||||
|
expect(credited).toBe(9000);
|
||||||
|
expect(state.resources.gold).toBe(3); // 9000 / 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
it('credits nothing when the clock did not advance', () => {
|
||||||
|
const content = testContent();
|
||||||
|
const state = createGameState(content);
|
||||||
|
startAction(state, content, 'forage');
|
||||||
|
expect(applyOfflineProgress(state, content, 5000, 4000)).toBe(0);
|
||||||
|
expect(state.resources.gold).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps credited time to the offline cap', () => {
|
||||||
|
const content = testContent();
|
||||||
|
const state = createGameState(content);
|
||||||
|
startAction(state, content, 'forage');
|
||||||
|
const credited = applyOfflineProgress(state, content, 0, 10_000_000, 6000);
|
||||||
|
expect(credited).toBe(6000);
|
||||||
|
expect(state.resources.gold).toBe(2); // 6000 / 3000
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { advance, createTickLoop } from '../tickLoop';
|
||||||
|
|
||||||
|
describe('createTickLoop()', () => {
|
||||||
|
it('defaults to the standard tick rate and an empty accumulator', () => {
|
||||||
|
const loop = createTickLoop();
|
||||||
|
expect(loop.tickMs).toBe(100);
|
||||||
|
expect(loop.tickCount).toBe(0);
|
||||||
|
expect(loop.accumulatorMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a custom tick length and start timestamp', () => {
|
||||||
|
const loop = createTickLoop({ tickMs: 250, startNow: 1000 });
|
||||||
|
expect(loop.tickMs).toBe(250);
|
||||||
|
expect(loop.lastNow).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('advance()', () => {
|
||||||
|
it('establishes a baseline without running ticks on the first call when no startNow given', () => {
|
||||||
|
const loop = createTickLoop({ tickMs: 100 });
|
||||||
|
expect(advance(loop, 5000)).toBe(0);
|
||||||
|
expect(loop.lastNow).toBe(5000);
|
||||||
|
expect(advance(loop, 5100)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs one tick per whole tickMs elapsed and keeps the remainder', () => {
|
||||||
|
const loop = createTickLoop({ tickMs: 100, startNow: 0 });
|
||||||
|
expect(advance(loop, 350)).toBe(3);
|
||||||
|
expect(loop.tickCount).toBe(3);
|
||||||
|
expect(loop.accumulatorMs).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onTick once per tick with an increasing tick index', () => {
|
||||||
|
const loop = createTickLoop({ tickMs: 100, startNow: 0 });
|
||||||
|
const seen: number[] = [];
|
||||||
|
advance(loop, 300, (i) => seen.push(i));
|
||||||
|
expect(seen).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores backward clock movement', () => {
|
||||||
|
const loop = createTickLoop({ tickMs: 100, startNow: 1000 });
|
||||||
|
expect(advance(loop, 500)).toBe(0);
|
||||||
|
expect(loop.lastNow).toBe(500);
|
||||||
|
expect(loop.tickCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('yields the same total tick count for the same elapsed time regardless of chunking', () => {
|
||||||
|
const tickMs = 100;
|
||||||
|
const elapsedMs = 10_000;
|
||||||
|
const expected = 100;
|
||||||
|
|
||||||
|
const whole = createTickLoop({ tickMs, startNow: 0 });
|
||||||
|
expect(advance(whole, elapsedMs)).toBe(expected);
|
||||||
|
|
||||||
|
const chunked = createTickLoop({ tickMs, startNow: 0 });
|
||||||
|
for (let t = 37; t < elapsedMs; t += 37) {
|
||||||
|
advance(chunked, t);
|
||||||
|
}
|
||||||
|
advance(chunked, elapsedMs);
|
||||||
|
expect(chunked.tickCount).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps ticks per advance and defers the remainder so none are lost (offline catch-up path)', () => {
|
||||||
|
const loop = createTickLoop({ tickMs: 100, startNow: 0 });
|
||||||
|
// 10s of elapsed = 100 ticks owed, but cap at 10 per call.
|
||||||
|
expect(advance(loop, 10_000, undefined, 10)).toBe(10);
|
||||||
|
expect(loop.tickCount).toBe(10);
|
||||||
|
|
||||||
|
let guard = 0;
|
||||||
|
while (advance(loop, 10_000, undefined, 10) > 0 && guard < 1000) {
|
||||||
|
guard += 1;
|
||||||
|
}
|
||||||
|
expect(loop.tickCount).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Content } from '../content/schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core game state and per-tick simulation.
|
||||||
|
*
|
||||||
|
* Pure TS — no React, no DOM, no wall clock. The tick loop (tickLoop.ts) drives
|
||||||
|
* `tickGame` once per tick; `now`/scheduling lives entirely outside this module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface GameState {
|
||||||
|
/** resourceId -> current amount. */
|
||||||
|
resources: Record<string, number>;
|
||||||
|
/** The action currently running, or null. */
|
||||||
|
activeActionId: string | null;
|
||||||
|
/** Progress of the active action, in milliseconds. */
|
||||||
|
actionElapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGameState(content: Content): GameState {
|
||||||
|
const resources: Record<string, number> = {};
|
||||||
|
for (const resource of content.resources) {
|
||||||
|
resources[resource.id] = resource.startAmount;
|
||||||
|
}
|
||||||
|
return { resources, activeActionId: null, actionElapsedMs: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begin running an action, resetting its progress. Throws on an unknown id. */
|
||||||
|
export function startAction(state: GameState, content: Content, actionId: string): void {
|
||||||
|
if (!content.actionsById[actionId]) {
|
||||||
|
throw new Error(`Unknown action "${actionId}"`);
|
||||||
|
}
|
||||||
|
state.activeActionId = actionId;
|
||||||
|
state.actionElapsedMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance the active action by `tickMs`. Each time it reaches its duration it
|
||||||
|
* grants its yield and repeats, carrying the remainder — so one large tick (the
|
||||||
|
* offline catch-up path) can complete an action many times.
|
||||||
|
*/
|
||||||
|
export function tickGame(state: GameState, content: Content, tickMs: number): void {
|
||||||
|
if (!state.activeActionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action = content.actionsById[state.activeActionId];
|
||||||
|
if (!action) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.actionElapsedMs += tickMs;
|
||||||
|
while (state.actionElapsedMs >= action.durationMs) {
|
||||||
|
state.actionElapsedMs -= action.durationMs;
|
||||||
|
state.resources[action.yields.resourceId] += action.yields.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Numbers, engine-side.
|
||||||
|
*
|
||||||
|
* The design keeps values human-readable (≤ billions, K/M/B suffixes, never
|
||||||
|
* scientific notation — see GDD D-0009). Plain JS numbers suffice; the `Num`
|
||||||
|
* brand documents "this number is a game quantity" without runtime cost. If a
|
||||||
|
* late prestige layer ever needs arbitrary precision, swap the brand for a
|
||||||
|
* Decimal wrapper here — call sites that use `num()`/`formatNum()` stay put.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare const numBrand: unique symbol;
|
||||||
|
export type Num = number & { readonly [numBrand]: true };
|
||||||
|
|
||||||
|
/** Brand a finite number as a game quantity. Throws on NaN/Infinity. */
|
||||||
|
export function num(value: number): Num {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
throw new RangeError(`num() requires a finite value, got ${value}`);
|
||||||
|
}
|
||||||
|
return value as Num;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUFFIX_TIERS = [
|
||||||
|
{ limit: 1e9, div: 1e9, suffix: 'B' },
|
||||||
|
{ limit: 1e6, div: 1e6, suffix: 'M' },
|
||||||
|
{ limit: 1e3, div: 1e3, suffix: 'K' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Round to ≤2 decimals, trim trailing zeros, add thousands separators. */
|
||||||
|
function formatScaled(value: number): string {
|
||||||
|
const rounded = Math.round(value * 100) / 100;
|
||||||
|
const raw = Number.isInteger(rounded)
|
||||||
|
? String(rounded)
|
||||||
|
: rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
||||||
|
|
||||||
|
const [intPart, fracPart] = raw.split('.');
|
||||||
|
const withSeparators = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
return fracPart ? `${withSeparators}.${fracPart}` : withSeparators;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable rendering: `1.23K`, `1.5M`, `2B`, `1,500B`. No `e` notation. */
|
||||||
|
export function formatNum(value: Num | number): string {
|
||||||
|
const n = value as number;
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = n < 0 ? '-' : '';
|
||||||
|
const abs = Math.abs(n);
|
||||||
|
|
||||||
|
for (const tier of SUFFIX_TIERS) {
|
||||||
|
if (abs >= tier.limit) {
|
||||||
|
return `${sign}${formatScaled(abs / tier.div)}${tier.suffix}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${sign}${formatScaled(abs)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Content } from '../content/schema';
|
||||||
|
import type { GameState } from './game';
|
||||||
|
import { tickGame } from './game';
|
||||||
|
import { TICK_MS } from './tickLoop';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save format, serialization, and offline crediting.
|
||||||
|
*
|
||||||
|
* Pure TS — no IndexedDB, no DOM. The IO adapter (idb-keyval + localStorage
|
||||||
|
* fallback) lives in src/state/persistence.ts; this module only turns game state
|
||||||
|
* into a validated, versioned, compressed string and back. Saves carry a
|
||||||
|
* `version` so future migrations have a hook; for now a version mismatch is
|
||||||
|
* rejected cleanly rather than silently coerced.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SAVE_VERSION = 1;
|
||||||
|
|
||||||
|
/** Default cap on credited offline time: 24 hours. */
|
||||||
|
export const DEFAULT_MAX_OFFLINE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export const gameStateSchema = z.object({
|
||||||
|
resources: z.record(z.string(), z.number()),
|
||||||
|
activeActionId: z.string().nullable(),
|
||||||
|
actionElapsedMs: z.number().nonnegative(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const saveSchema = z.object({
|
||||||
|
version: z.number().int().nonnegative(),
|
||||||
|
savedAt: z.number().nonnegative(),
|
||||||
|
state: gameStateSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SaveData = z.infer<typeof saveSchema>;
|
||||||
|
|
||||||
|
/** Snapshot the current state into a versioned, timestamped save. */
|
||||||
|
export function createSave(state: GameState, now: number): SaveData {
|
||||||
|
return {
|
||||||
|
version: SAVE_VERSION,
|
||||||
|
savedAt: now,
|
||||||
|
state: {
|
||||||
|
resources: { ...state.resources },
|
||||||
|
activeActionId: state.activeActionId,
|
||||||
|
actionElapsedMs: state.actionElapsedMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSave(save: SaveData): string {
|
||||||
|
return JSON.stringify(save);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse + validate a save from JSON. Throws on malformed or unsupported saves. */
|
||||||
|
export function deserializeSave(json: string): SaveData {
|
||||||
|
const parsed: unknown = JSON.parse(json);
|
||||||
|
const save = saveSchema.parse(parsed);
|
||||||
|
if (save.version !== SAVE_VERSION) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported save version ${save.version} (this build expects ${SAVE_VERSION})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return save;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toExportString(save: SaveData): string {
|
||||||
|
return compressToEncodedURIComponent(serializeSave(save));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decompress + validate an export string. Throws cleanly on tampered input. */
|
||||||
|
export function fromExportString(compressed: string): SaveData {
|
||||||
|
const json = decompressFromEncodedURIComponent(compressed);
|
||||||
|
if (json === null || json === '') {
|
||||||
|
throw new Error('Save string is corrupt or empty');
|
||||||
|
}
|
||||||
|
return deserializeSave(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credit elapsed wall-clock time to the running simulation on load, using the
|
||||||
|
* same per-tick path the live loop uses. Elapsed is clamped to `maxOfflineMs`
|
||||||
|
* and floored to whole ticks. Returns the milliseconds actually credited.
|
||||||
|
*/
|
||||||
|
export function applyOfflineProgress(
|
||||||
|
state: GameState,
|
||||||
|
content: Content,
|
||||||
|
savedAt: number,
|
||||||
|
now: number,
|
||||||
|
maxOfflineMs: number = DEFAULT_MAX_OFFLINE_MS,
|
||||||
|
): number {
|
||||||
|
const elapsed = Math.max(0, now - savedAt);
|
||||||
|
const credited = Math.min(elapsed, maxOfflineMs);
|
||||||
|
const ticks = Math.floor(credited / TICK_MS);
|
||||||
|
for (let i = 0; i < ticks; i += 1) {
|
||||||
|
tickGame(state, content, TICK_MS);
|
||||||
|
}
|
||||||
|
return credited;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Fixed-timestep tick loop with an accumulator.
|
||||||
|
*
|
||||||
|
* The engine advances in whole ticks of `tickMs`. `advance(loop, now)` folds the
|
||||||
|
* elapsed wall-clock time into an accumulator and drains it one tick at a time,
|
||||||
|
* keeping the sub-tick remainder so results are independent of how the caller
|
||||||
|
* chunks calls (determinism — see the test suite).
|
||||||
|
*
|
||||||
|
* Offline catch-up is the *same* code path: a large `now` delta simply owes many
|
||||||
|
* ticks. `maxTicks` caps how many run per call so a huge delta can't lock the
|
||||||
|
* thread; the remainder stays in the accumulator and drains on the next call,
|
||||||
|
* so no ticks are ever lost. Clamping the *credited* offline window lives in the
|
||||||
|
* save layer (T3.3), not here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TICK_HZ = 10;
|
||||||
|
export const TICK_MS = 1000 / TICK_HZ;
|
||||||
|
export const DEFAULT_MAX_TICKS_PER_ADVANCE = 100_000;
|
||||||
|
|
||||||
|
export interface TickLoop {
|
||||||
|
/** Milliseconds of simulated time per tick. */
|
||||||
|
readonly tickMs: number;
|
||||||
|
/** Total ticks run over this loop's lifetime. */
|
||||||
|
tickCount: number;
|
||||||
|
/** Sub-tick wall-clock remainder, in milliseconds. */
|
||||||
|
accumulatorMs: number;
|
||||||
|
/** Last timestamp seen, or null until the first `advance` establishes a baseline. */
|
||||||
|
lastNow: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTickLoop(opts?: { tickMs?: number; startNow?: number }): TickLoop {
|
||||||
|
const tickMs = opts?.tickMs ?? TICK_MS;
|
||||||
|
if (!(tickMs > 0)) {
|
||||||
|
throw new RangeError(`tickMs must be > 0, got ${tickMs}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tickMs,
|
||||||
|
tickCount: 0,
|
||||||
|
accumulatorMs: 0,
|
||||||
|
lastNow: opts?.startNow ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance the loop to `now`, running owed ticks (capped at `maxTicks`).
|
||||||
|
* Returns the number of ticks run this call.
|
||||||
|
*/
|
||||||
|
export function advance(
|
||||||
|
loop: TickLoop,
|
||||||
|
now: number,
|
||||||
|
onTick?: (tickIndex: number) => void,
|
||||||
|
maxTicks: number = DEFAULT_MAX_TICKS_PER_ADVANCE,
|
||||||
|
): number {
|
||||||
|
if (loop.lastNow === null) {
|
||||||
|
loop.lastNow = now;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = now - loop.lastNow;
|
||||||
|
loop.lastNow = now;
|
||||||
|
if (elapsed > 0) {
|
||||||
|
loop.accumulatorMs += elapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ran = 0;
|
||||||
|
while (loop.accumulatorMs >= loop.tickMs && ran < maxTicks) {
|
||||||
|
loop.accumulatorMs -= loop.tickMs;
|
||||||
|
loop.tickCount += 1;
|
||||||
|
ran += 1;
|
||||||
|
onTick?.(loop.tickCount);
|
||||||
|
}
|
||||||
|
return ran;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100dvh;
|
||||||
|
background-color: #020617; /* slate-950 */
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import { App } from './ui/App';
|
||||||
|
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
if (!root) {
|
||||||
|
throw new Error('Root element #root not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Zustand bridge: engine snapshots -> view (~10 fps). Built in T3.4.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildContent } from '../../content/schema';
|
||||||
|
import { createGameState, startAction } from '../../engine/game';
|
||||||
|
import { createSave, serializeSave } from '../../engine/save';
|
||||||
|
import { createMemoryBackend, loadGame, saveGame } from '../persistence';
|
||||||
|
|
||||||
|
function testContent() {
|
||||||
|
return buildContent({
|
||||||
|
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
|
||||||
|
actions: [
|
||||||
|
{ id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('loadGame()', () => {
|
||||||
|
it('returns a fresh game when no save exists', async () => {
|
||||||
|
const content = testContent();
|
||||||
|
const result = await loadGame(content, createMemoryBackend(), 1000);
|
||||||
|
expect(result.state.resources.gold).toBe(0);
|
||||||
|
expect(result.offlineMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a saved game and credits offline progress', async () => {
|
||||||
|
const content = testContent();
|
||||||
|
const state = createGameState(content);
|
||||||
|
state.resources.gold = 10;
|
||||||
|
startAction(state, content, 'forage');
|
||||||
|
const backend = createMemoryBackend();
|
||||||
|
await saveGame(state, backend, 1000);
|
||||||
|
|
||||||
|
const result = await loadGame(content, backend, 1000 + 9000); // 9s offline
|
||||||
|
expect(result.offlineMs).toBe(9000);
|
||||||
|
expect(result.state.resources.gold).toBe(13); // 10 + 9000/3000
|
||||||
|
expect(result.state.activeActionId).toBe('forage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a fresh game on a corrupt save instead of throwing', async () => {
|
||||||
|
const content = testContent();
|
||||||
|
const result = await loadGame(content, createMemoryBackend('@@@garbage@@@'), 1000);
|
||||||
|
expect(result.state.resources.gold).toBe(0);
|
||||||
|
expect(result.offlineMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops an active action that no longer exists in content', async () => {
|
||||||
|
const content = testContent();
|
||||||
|
const stale = serializeSave(
|
||||||
|
createSave(
|
||||||
|
{ resources: { gold: 1 }, activeActionId: 'ghost-action', actionElapsedMs: 0 },
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const result = await loadGame(content, createMemoryBackend(stale), 1000);
|
||||||
|
expect(result.state.activeActionId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildContent } from '../../content/schema';
|
||||||
|
import { createGameState, startAction } from '../../engine/game';
|
||||||
|
import { formatOfflineDuration, toView } from '../viewModel';
|
||||||
|
|
||||||
|
function testContent() {
|
||||||
|
return buildContent({
|
||||||
|
resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }],
|
||||||
|
actions: [
|
||||||
|
{ id: 'forage', name: 'Forage', durationMs: 200, yields: { resourceId: 'gold', amount: 1 } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('toView()', () => {
|
||||||
|
it('maps resources with their names and amounts', () => {
|
||||||
|
const content = testContent();
|
||||||
|
const view = toView(createGameState(content), content);
|
||||||
|
expect(view.resources).toEqual([{ id: 'gold', name: 'Gold', amount: 4 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no progress and no action name when idle', () => {
|
||||||
|
const content = testContent();
|
||||||
|
const view = toView(createGameState(content), content);
|
||||||
|
expect(view.activeActionId).toBeNull();
|
||||||
|
expect(view.actionName).toBeNull();
|
||||||
|
expect(view.actionProgress).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the active action name and fractional progress', () => {
|
||||||
|
const content = testContent();
|
||||||
|
const state = createGameState(content);
|
||||||
|
startAction(state, content, 'forage');
|
||||||
|
state.actionElapsedMs = 50; // of 200ms
|
||||||
|
const view = toView(state, content);
|
||||||
|
expect(view.actionName).toBe('Forage');
|
||||||
|
expect(view.actionProgress).toBeCloseTo(0.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps progress to at most 1', () => {
|
||||||
|
const content = testContent();
|
||||||
|
const state = createGameState(content);
|
||||||
|
startAction(state, content, 'forage');
|
||||||
|
state.actionElapsedMs = 999;
|
||||||
|
expect(toView(state, content).actionProgress).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatOfflineDuration()', () => {
|
||||||
|
it('formats sub-minute durations in seconds', () => {
|
||||||
|
expect(formatOfflineDuration(0)).toBe('0s');
|
||||||
|
expect(formatOfflineDuration(45_000)).toBe('45s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats minutes with seconds', () => {
|
||||||
|
expect(formatOfflineDuration(90_000)).toBe('1m 30s');
|
||||||
|
expect(formatOfflineDuration(120_000)).toBe('2m');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats hours with minutes', () => {
|
||||||
|
expect(formatOfflineDuration(3_660_000)).toBe('1h 1m');
|
||||||
|
expect(formatOfflineDuration(7_200_000)).toBe('2h');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { del, get, set } from 'idb-keyval';
|
||||||
|
import type { Content } from '../content/schema';
|
||||||
|
import { createGameState, type GameState } from '../engine/game';
|
||||||
|
import { applyOfflineProgress, createSave, deserializeSave, serializeSave } from '../engine/save';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistence adapter (IO layer — deliberately outside the pure engine).
|
||||||
|
*
|
||||||
|
* A `SaveBackend` is a pluggable string store; the orchestration (`loadGame`,
|
||||||
|
* `saveGame`) turns it into typed game state via the engine's save module and
|
||||||
|
* credits offline progress on boot. The real backend prefers IndexedDB
|
||||||
|
* (idb-keyval) and falls back to localStorage, then to an in-memory store so the
|
||||||
|
* game still runs (without persistence) in hostile environments.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SAVE_KEY = 'idlegame:save:v1';
|
||||||
|
|
||||||
|
export interface SaveBackend {
|
||||||
|
load(): Promise<string | null>;
|
||||||
|
save(serialized: string): Promise<void>;
|
||||||
|
clear(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMemoryBackend(initial: string | null = null): SaveBackend {
|
||||||
|
let value = initial;
|
||||||
|
return {
|
||||||
|
load: () => Promise.resolve(value),
|
||||||
|
save: (serialized) => {
|
||||||
|
value = serialized;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
value = null;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocalStorageBackend(key: string): SaveBackend {
|
||||||
|
return {
|
||||||
|
load: () => Promise.resolve(globalThis.localStorage.getItem(key)),
|
||||||
|
save: (serialized) => {
|
||||||
|
globalThis.localStorage.setItem(key, serialized);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
globalThis.localStorage.removeItem(key);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIdbBackend(key: string): SaveBackend {
|
||||||
|
return {
|
||||||
|
load: async () => (await get<string>(key)) ?? null,
|
||||||
|
save: (serialized) => set(key, serialized),
|
||||||
|
clear: () => del(key),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Choose the best available backend for the current environment. */
|
||||||
|
export function createDefaultBackend(key: string = SAVE_KEY): SaveBackend {
|
||||||
|
if (typeof indexedDB !== 'undefined') {
|
||||||
|
return createIdbBackend(key);
|
||||||
|
}
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
return createLocalStorageBackend(key);
|
||||||
|
}
|
||||||
|
return createMemoryBackend();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadResult {
|
||||||
|
state: GameState;
|
||||||
|
/** Milliseconds of offline time credited on this load (0 for a fresh game). */
|
||||||
|
offlineMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and hydrate game state, crediting offline progress. A missing or corrupt
|
||||||
|
* save yields a fresh game rather than throwing — never block boot on bad data.
|
||||||
|
*/
|
||||||
|
export async function loadGame(
|
||||||
|
content: Content,
|
||||||
|
backend: SaveBackend,
|
||||||
|
now: number,
|
||||||
|
): Promise<LoadResult> {
|
||||||
|
const raw = await backend.load();
|
||||||
|
if (!raw) {
|
||||||
|
return { state: createGameState(content), offlineMs: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let savedAt: number;
|
||||||
|
let state: GameState;
|
||||||
|
try {
|
||||||
|
const save = deserializeSave(raw);
|
||||||
|
const base = createGameState(content);
|
||||||
|
const activeActionId =
|
||||||
|
save.state.activeActionId && content.actionsById[save.state.activeActionId]
|
||||||
|
? save.state.activeActionId
|
||||||
|
: null;
|
||||||
|
state = {
|
||||||
|
resources: { ...base.resources, ...save.state.resources },
|
||||||
|
activeActionId,
|
||||||
|
actionElapsedMs: save.state.actionElapsedMs,
|
||||||
|
};
|
||||||
|
savedAt = save.savedAt;
|
||||||
|
} catch {
|
||||||
|
return { state: createGameState(content), offlineMs: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const offlineMs = applyOfflineProgress(state, content, savedAt, now);
|
||||||
|
return { state, offlineMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveGame(state: GameState, backend: SaveBackend, now: number): Promise<void> {
|
||||||
|
await backend.save(serializeSave(createSave(state, now)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { content } from '../content';
|
||||||
|
import { startAction as engineStartAction, type GameState, tickGame } from '../engine/game';
|
||||||
|
import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop';
|
||||||
|
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
|
||||||
|
import { useGameStore } from './store';
|
||||||
|
import { formatOfflineDuration, toView } from './viewModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The game runtime: the one place that owns the engine state and the wall clock.
|
||||||
|
*
|
||||||
|
* It drives the fixed-timestep tick loop off requestAnimationFrame, mirrors a
|
||||||
|
* view snapshot into the Zustand store at ~10 fps, autosaves on an interval, and
|
||||||
|
* persists on tab-hide / unload. Boot loads the save and credits offline time.
|
||||||
|
* All environment coupling (RAF, Date.now, DOM events, IndexedDB) lives here so
|
||||||
|
* the engine stays pure.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh
|
||||||
|
const AUTOSAVE_INTERVAL_MS = 10_000;
|
||||||
|
|
||||||
|
class GameRuntime {
|
||||||
|
private state: GameState | null = null;
|
||||||
|
private readonly loop: TickLoop = createTickLoop();
|
||||||
|
private readonly backend: SaveBackend = createDefaultBackend();
|
||||||
|
private rafId: number | null = null;
|
||||||
|
private lastPublishAt = 0;
|
||||||
|
private lastSaveAt = 0;
|
||||||
|
private booted = false;
|
||||||
|
|
||||||
|
async boot(): Promise<void> {
|
||||||
|
if (this.booted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.booted = true;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const { state, offlineMs } = await loadGame(content, this.backend, now);
|
||||||
|
this.state = state;
|
||||||
|
this.lastSaveAt = now;
|
||||||
|
|
||||||
|
const store = useGameStore.getState();
|
||||||
|
store.appendLog(
|
||||||
|
offlineMs >= 1000
|
||||||
|
? `Welcome back — credited ${formatOfflineDuration(offlineMs)} of offline progress.`
|
||||||
|
: 'A new tale begins. Choose an action.',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.publish();
|
||||||
|
this.installLifecycleHooks();
|
||||||
|
this.rafId = requestAnimationFrame(this.frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the loop. Used on teardown (e.g. HMR dispose); not needed in normal play. */
|
||||||
|
stop(): void {
|
||||||
|
if (this.rafId !== null) {
|
||||||
|
cancelAnimationFrame(this.rafId);
|
||||||
|
this.rafId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startAction(actionId: string): void {
|
||||||
|
const state = this.state;
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
engineStartAction(state, content, actionId);
|
||||||
|
const action = content.actionsById[actionId];
|
||||||
|
if (action) {
|
||||||
|
useGameStore.getState().appendLog(`Started: ${action.name}.`);
|
||||||
|
}
|
||||||
|
this.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly frame = (monoNow: number): void => {
|
||||||
|
const state = this.state;
|
||||||
|
if (state) {
|
||||||
|
advance(this.loop, monoNow, () => tickGame(state, content, TICK_MS));
|
||||||
|
|
||||||
|
if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) {
|
||||||
|
this.publish();
|
||||||
|
this.lastPublishAt = monoNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wall = Date.now();
|
||||||
|
if (wall - this.lastSaveAt >= AUTOSAVE_INTERVAL_MS) {
|
||||||
|
void this.save(wall);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.rafId = requestAnimationFrame(this.frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
private publish(): void {
|
||||||
|
const state = this.state;
|
||||||
|
if (state) {
|
||||||
|
useGameStore.getState().setView(toView(state, content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async save(now: number = Date.now()): Promise<void> {
|
||||||
|
const state = this.state;
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastSaveAt = now;
|
||||||
|
await saveGame(state, this.backend, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private installLifecycleHooks(): void {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
void this.save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
void this.save();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gameRuntime = new GameRuntime();
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { GameView } from './viewModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The view store. The runtime owns the authoritative engine state and pushes a
|
||||||
|
* fresh view snapshot here at ~10 fps; React components subscribe to slices of
|
||||||
|
* it. The store is a dumb mirror plus an event log — no game logic lives here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_LOG_LINES = 50;
|
||||||
|
|
||||||
|
export interface GameStoreState extends GameView {
|
||||||
|
log: string[];
|
||||||
|
setView: (view: GameView) => void;
|
||||||
|
appendLog: (line: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGameStore = create<GameStoreState>((set) => ({
|
||||||
|
resources: [],
|
||||||
|
activeActionId: null,
|
||||||
|
actionName: null,
|
||||||
|
actionProgress: 0,
|
||||||
|
log: [],
|
||||||
|
setView: (view) => set(view),
|
||||||
|
appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Content } from '../content/schema';
|
||||||
|
import type { GameState } from '../engine/game';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure mapping from engine state to the view model the React shell renders.
|
||||||
|
* No React, no store — just data shaping, so it can be unit-tested directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ResourceView {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameView {
|
||||||
|
resources: ResourceView[];
|
||||||
|
activeActionId: string | null;
|
||||||
|
actionName: string | null;
|
||||||
|
/** Progress of the active action, clamped to 0..1. */
|
||||||
|
actionProgress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toView(state: GameState, content: Content): GameView {
|
||||||
|
const resources: ResourceView[] = content.resources.map((resource) => ({
|
||||||
|
id: resource.id,
|
||||||
|
name: resource.name,
|
||||||
|
amount: state.resources[resource.id] ?? 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const action = state.activeActionId ? content.actionsById[state.activeActionId] : undefined;
|
||||||
|
const actionProgress = action ? Math.min(1, state.actionElapsedMs / action.durationMs) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
resources,
|
||||||
|
activeActionId: state.activeActionId,
|
||||||
|
actionName: action ? action.name : null,
|
||||||
|
actionProgress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render an offline gap as `45s`, `1m 30s`, `2m`, `1h 1m`, `2h`. */
|
||||||
|
export function formatOfflineDuration(ms: number): string {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
if (totalSeconds < 60) {
|
||||||
|
return `${totalSeconds}s`;
|
||||||
|
}
|
||||||
|
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||||
|
if (totalMinutes < 60) {
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return seconds === 0 ? `${totalMinutes}m` : `${totalMinutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { content } from '../content';
|
||||||
|
import { gameRuntime } from '../state/runtime';
|
||||||
|
import { useGameStore } from '../state/store';
|
||||||
|
|
||||||
|
/** Action list — a start button per action, with a progress bar on the active one. */
|
||||||
|
export function ActionPanel() {
|
||||||
|
const activeActionId = useGameStore((state) => state.activeActionId);
|
||||||
|
const actionProgress = useGameStore((state) => state.actionProgress);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Actions" className="flex flex-col gap-2">
|
||||||
|
<h2 className="font-medium text-slate-300 text-sm uppercase tracking-wide">Actions</h2>
|
||||||
|
{content.actions.map((action) => {
|
||||||
|
const isActive = action.id === activeActionId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={action.id}
|
||||||
|
onClick={() => gameRuntime.startAction(action.id)}
|
||||||
|
className="relative overflow-hidden rounded-lg border border-slate-700 bg-slate-800/70 px-4 py-3 text-left transition-colors hover:border-amber-500/60 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
{isActive ? (
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 bg-amber-500/15"
|
||||||
|
style={{ width: `${Math.round(actionProgress * 100)}%` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="relative flex items-center justify-between">
|
||||||
|
<span className="font-medium text-slate-100">{action.name}</span>
|
||||||
|
<span className="text-slate-400 text-xs">{isActive ? 'running…' : 'start'}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { gameRuntime } from '../state/runtime';
|
||||||
|
import { ActionPanel } from './ActionPanel';
|
||||||
|
import { EventLog } from './EventLog';
|
||||||
|
import { ResourceBar } from './ResourceBar';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walking-skeleton view shell. Boots the runtime once on mount; everything else
|
||||||
|
* renders from the Zustand store the runtime feeds. M1 turns this into a game.
|
||||||
|
*/
|
||||||
|
export function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
void gameRuntime.boot();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col gap-5 px-4 py-8 text-slate-100">
|
||||||
|
<header>
|
||||||
|
<h1 className="font-bold text-2xl tracking-tight">Idlegame</h1>
|
||||||
|
<p className="text-slate-500 text-sm">Walking skeleton — M0 scaffold.</p>
|
||||||
|
</header>
|
||||||
|
<ResourceBar />
|
||||||
|
<ActionPanel />
|
||||||
|
<EventLog />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useGameStore } from '../state/store';
|
||||||
|
|
||||||
|
/** Event log stub — newest entries at the bottom. */
|
||||||
|
export function EventLog() {
|
||||||
|
const log = useGameStore((state) => state.log);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Event log" className="flex flex-col gap-1">
|
||||||
|
<h2 className="font-medium text-slate-300 text-sm uppercase tracking-wide">Log</h2>
|
||||||
|
<ol className="flex max-h-48 flex-col gap-1 overflow-y-auto rounded-lg border border-slate-800 bg-slate-900/60 p-3 text-sm">
|
||||||
|
{log.length === 0 ? (
|
||||||
|
<li className="text-slate-500">…</li>
|
||||||
|
) : (
|
||||||
|
log.map((line, index) => (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: log is append-only; index is stable
|
||||||
|
<li key={index} className="text-slate-400">
|
||||||
|
{line}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { formatNum } from '../engine/num';
|
||||||
|
import { useGameStore } from '../state/store';
|
||||||
|
|
||||||
|
/** Resource readout — name + human-readable amount for each resource. */
|
||||||
|
export function ResourceBar() {
|
||||||
|
const resources = useGameStore((state) => state.resources);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Resources"
|
||||||
|
className="flex flex-wrap gap-3 rounded-lg border border-slate-800 bg-slate-900/60 p-3"
|
||||||
|
>
|
||||||
|
{resources.length === 0 ? (
|
||||||
|
<span className="text-slate-500 text-sm">Loading…</span>
|
||||||
|
) : (
|
||||||
|
resources.map((resource) => (
|
||||||
|
<div key={resource.id} className="flex items-baseline gap-2">
|
||||||
|
<span className="text-slate-400 text-sm">{resource.name}</span>
|
||||||
|
<span className="font-semibold text-amber-400 tabular-nums">
|
||||||
|
{formatNum(resource.amount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/// <reference types="vitest/config" />
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
manifest: {
|
||||||
|
name: 'Idlegame',
|
||||||
|
short_name: 'Idlegame',
|
||||||
|
description: 'Idle/incremental text-fantasy RPG.',
|
||||||
|
theme_color: '#0f172a',
|
||||||
|
background_color: '#0f172a',
|
||||||
|
display: 'standalone',
|
||||||
|
start_url: '/',
|
||||||
|
icons: [
|
||||||
|
{ src: 'pwa-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
globals: false,
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
include: ['src/engine/**'],
|
||||||
|
thresholds: {
|
||||||
|
lines: 80,
|
||||||
|
functions: 80,
|
||||||
|
branches: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user