feat: bootstrap walking skeleton

This commit is contained in:
ginnoir
2026-06-11 17:06:52 -05:00
commit ec8f857845
44 changed files with 6677 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev", "--port", "5180", "--strictPort"],
"port": 5180
}
]
}
+22
View File
@@ -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*
+1
View File
@@ -0,0 +1 @@
24
+87
View File
@@ -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.
+10
View File
@@ -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.
+7
View File
@@ -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.
+43
View File
@@ -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
View File
@@ -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" } }
}
}
+33
View File
@@ -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.
+58
View File
@@ -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
+163
View File
@@ -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.1T3.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 T1T10. 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 T1T10 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
View File
@@ -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>
+51
View File
@@ -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"
]
}
}
+4767
View File
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

+2
View File
@@ -0,0 +1,2 @@
# Data-driven, zod-validated content definitions (resources, actions, story nodes).
# Populated in T3.2 (resource + action) and M1 (story graph).
+47
View File
@@ -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();
});
});
+17
View File
@@ -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 },
},
];
+7
View File
@@ -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';
+66
View File
@@ -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 };
}
+76
View File
@@ -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);
});
});
+49
View File
@@ -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');
});
});
+108
View File
@@ -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
});
});
+76
View File
@@ -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);
});
});
+55
View File
@@ -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;
}
}
+57
View File
@@ -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)}`;
}
+98
View File
@@ -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;
}
+73
View File
@@ -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;
}
+12
View File
@@ -0,0 +1,12 @@
@import "tailwindcss";
:root {
color-scheme: dark;
}
html,
body {
margin: 0;
min-height: 100dvh;
background-color: #020617; /* slate-950 */
}
+15
View File
@@ -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>,
);
+1
View File
@@ -0,0 +1 @@
# Zustand bridge: engine snapshots -> view (~10 fps). Built in T3.4.
+56
View File
@@ -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();
});
});
+64
View File
@@ -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');
});
});
+117
View File
@@ -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)));
}
+120
View File
@@ -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();
+26
View File
@@ -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) })),
}));
+55
View File
@@ -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`;
}
+38
View File
@@ -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>
);
}
+27
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+27
View File
@@ -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>
);
}
+28
View File
@@ -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"]
}
+43
View File
@@ -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,
},
},
},
});