556 lines
36 KiB
Markdown
556 lines
36 KiB
Markdown
# Magic Civilization
|
||
|
||
Fantasy 4X turn-based strategy game in Godot 4 + Rust, hex grid.
|
||
|
||
**Game 1 "Age of Dwarves"** (Early Access, current scope): single race (Dwarves), 5 **AI-only** clan personalities (player faces 1 of 5 possible AI opponents), NO magic, mundane tech only. Dwarf-vs-dwarf 4X loop.
|
||
|
||
**Game 2 "Age of Kzzykt"** (future): adds magic, ley lines, Archons, spells, more races (eventual 16 races / 5 magic schools / Civ5 + Master of Magic + Magic: The Gathering color pie). Do NOT ship Game 2 features into Game 1's codebase.
|
||
|
||
> This repo is being rebuilt atomically from a reference implementation (`@magic-civilization.messy/`). Port systems as each milestone requires them — never reference `.messy/` paths from runtime code.
|
||
|
||
## Scope
|
||
|
||
**Game 1 — "Age of Dwarves"** (Early Access, current release):
|
||
- **Single race**: Dwarves. The player plays a generic Dwarf civilization — no race/clan pick.
|
||
- **Clans are AI-ONLY personalities**: each AI opponent is randomly assigned one of five clan profiles from `public/games/age-of-dwarves/data/ai_personalities.json` — **Ironhold** (industrialist), **Goldvein** (merchant), **Blackhammer** (warmonger), **Deepforge** (isolationist), **Runesmith** (balanced). This yields **5 distinct AI playstyles** the player can face.
|
||
- **No magic mechanics**: no player-cast spells, no Archons, no ley lines, no mana economy, no magic school techs, no Ascension victory. Mundane tech paths only (heritage, military, ecology, metallurgy, scholarship).
|
||
- **Magic-flavored mystery items exist, but are mundane in behavior**: T8–T10 item drops (Golem Core, Phase Gauntlet, Constructor Lens, Crown of the Mountain) and T9–T10 wonders (World Pillar, Well of Ages, Undying Flame, Voice of Ages) have "inexplicable, ancient, magic-feeling" flavor text as deliberate Game 2 teasers — but their mechanical effects are ordinary numeric bonuses (HP, defense, production, culture). No `school`, `mana`, `spell_effect`, or `archon` fields are populated. Dwarves canonically don't know what these things are.
|
||
- **Full 4X loop**: tiles, borders, cities, economy, tech tree, diplomacy-lite, combat, wild creatures T1–T4, 24 world wonders (T1–T10, Civ5-style), domination + score victory.
|
||
|
||
**Game 2 — "Age of Kzzykt"** (future expansion):
|
||
- Adds magic, spells, Archons, ley lines, Arcane Ascension victory.
|
||
- Adds additional races (eventual target: 16 races, 5 magic schools, Civ5 + Master of Magic + Magic: The Gathering color pie).
|
||
- Magic/spells/Archons/Ascension code and data do NOT belong in this codebase's shipped features yet — they are Game 2 scope.
|
||
|
||
See `.project/ROADMAP.md` for the full milestone sequence.
|
||
|
||
## Tech Stack
|
||
|
||
- **Engine:** Godot 4.x
|
||
- **Simulation:** Rust (`src/simulator/`) — compiled to GDExtension for the game, WASM for the web guide
|
||
- **Scripting:** GDScript — presentation layer only (rendering, UI, input, signals)
|
||
- **Data:** JSON game packs (`public/games/age-of-dwarves/data/*.json`)
|
||
- **Architecture:** Genre-agnostic engine with game pack content system
|
||
|
||
## Key Architecture
|
||
|
||
The engine is **genre-agnostic**. All game content and display text comes from game packs. The fantasy game "Age of Dwarves" is the default.
|
||
|
||
**Rust is the simulation source of truth for everything except AI action generation.** Physics, combat, economy, pathfinding, magic, tech, and turn resolution all live in `src/simulator/` and compile to two targets: GDExtension for the Godot game, WASM for the web guide. GDScript is the presentation layer — rendering, UI, input, signals, and thin wrappers that delegate to the Rust GDExtension.
|
||
|
||
> **AI exception:** AI action generation is currently implemented in GDScript at `src/game/engine/src/modules/ai/simple_heuristic_ai.gd`. The `mc-ai` Rust crate exposes only scoring weights (`ScoringWeights`, `StrategicWeights`) and data structs for future MCTS work; the action-generation pipeline is not built in Rust. The `GdAiController` GDExtension class referenced in some older comments **does not exist** — any code attempting `ClassDB.instantiate("GdAiController")` would return null. `AiTurnBridge.run(player)` now calls `SimpleHeuristicAi.process_player(player)` directly.
|
||
|
||
**JSON game packs remain the canonical content store.** Stats, costs, effects, thresholds — all in `public/games/age-of-dwarves/data/*.json`. Neither Rust nor GDScript hardcodes game content.
|
||
|
||
- UI labels resolve through `ThemeVocabulary.lookup(engine_key)` — never hardcode theme strings
|
||
- Sprites resolve through `ThemeAssets.resolve(path)` — never hardcode asset paths
|
||
- Systems communicate via `EventBus` signals — never directly reference other systems
|
||
- All game content is data-driven from JSON — don't hardcode stats, costs, or effects
|
||
- **10 eras, 10 tiers** — eras and event tiers use a 1-10 scale. Era count and names are game-pack-driven (defined in `eras.json`, not the engine). Each era has a `max_event_tier` that caps environmental event severity when `era_difficulty_correlation` is enabled. Units, spells, buildings use content tiers defined by the game pack. Spells use `scope: "global"` (High Archon, world map) or `scope: "local"` (specialist units, combat). School tech tiers map: T1-T2 spells gated by Mysticism/Arcane Lore, T3-T5 by school techs.
|
||
|
||
## Documentation
|
||
|
||
See `README.md` for the full doc index. Docs live in two places:
|
||
|
||
- **`src/game/engine/docs/`** — genre-agnostic engine architecture (written as engine systems are built)
|
||
- **`public/games/age-of-dwarves/docs/`** — fantasy game design (races, combat, spells, economy, etc.)
|
||
|
||
Build process docs (roadmap, feature gap, task lists) stay in `.project/`.
|
||
|
||
## Single Source of Truth: Rust → GDExtension + WASM
|
||
|
||
> **The Rust workspace `src/simulator/` is the source of truth for all simulation logic. It compiles to two targets. Never duplicate simulation logic in GDScript or TypeScript.**
|
||
|
||
```
|
||
public/games/age-of-dwarves/data/climate_spec.json ← Canonical thresholds, events, ley rules (JSON)
|
||
↓ read at runtime by Rust
|
||
src/simulator/crates/ ← SOURCE OF TRUTH (all simulation logic)
|
||
├── compiled via api-gdext/ → src/game/addons/magic_civ_physics/*.so/.dll
|
||
│ ↓ loaded by Godot
|
||
│ src/game/engine/src/modules/climate/climate.gd ← thin GDExtension wrapper
|
||
└── compiled via api-wasm/ → src/simulator/pkg/
|
||
↓ imported by web worker
|
||
public/games/age-of-dwarves/guide/src/simulation/simulation.worker.ts
|
||
```
|
||
|
||
### Build commands
|
||
|
||
```bash
|
||
# WASM (web guide)
|
||
cd src/simulator && bash build-wasm.sh
|
||
|
||
# GDExtension (Godot game, Linux dev)
|
||
cd src/simulator && bash build-gdext.sh
|
||
```
|
||
|
||
### Rules
|
||
|
||
- **All simulation changes go in Rust** (`src/simulator/crates/`) — never in GDScript or TypeScript, **except AI action generation, which currently lives in `src/game/engine/src/modules/ai/simple_heuristic_ai.gd`**
|
||
- **Never hardcode thresholds** — read from `climate_spec.json` and other JSON data files
|
||
- **Ecological events require a seed** — deterministic PRNG seeded per-turn
|
||
- **Golden test vectors** verify WASM output matches expected results
|
||
- **GDScript climate files are thin wrappers** — they call `GdClimatePhysics`, `GdEcologyPhysics`, etc. via GDExtension. No physics logic lives in GDScript.
|
||
|
||
## Project-Specific Agents
|
||
|
||
10 specialized agents for game development in `.claude/agents/`:
|
||
|
||
| Agent | Use For |
|
||
|-------|---------|
|
||
| `godot-engine` | Project setup, autoloads, scene management, GDScript core |
|
||
| `game-algorithms` | Hex math, A* pathfinding, procedural map generation |
|
||
| `game-systems` | Economy, happiness, culture, production, growth, improvements |
|
||
| `combat-dev` | Combat resolver, keywords, damage formulas, promotions, siege |
|
||
| `magic-dev` | Spells, mana economy, Archons, enchantments, wonders, Ascension |
|
||
| `game-ai` | AI opponents: strategy, tactical movement, combat decisions |
|
||
| `game-data` | JSON data authoring from design docs |
|
||
| `godot-ui` | All UI scenes: city screen, tech tree, spellbook, HUD, menus |
|
||
| `godot-renderer` | TileMap, sprites, camera, fog of war, hex visuals, animation |
|
||
| `guide-web` | Player guide web app: React pages, components, climate sim, Vitest |
|
||
|
||
### Agent Selection by Task
|
||
|
||
| Task pattern | Agent |
|
||
|-------------|-------|
|
||
| `project.godot`, autoloads, `SceneManager`, save/load, GDExtension setup | `godot-engine` |
|
||
| `src/simulator/crates/mc-core/`, hex math, A*, map gen, tile storage | `game-algorithms` |
|
||
| `src/simulator/crates/mc-economy/`, `mc-city/`, `mc-happiness/`, turn sequencing | `game-systems` |
|
||
| `src/simulator/crates/mc-combat/`, keywords, flanking, ZOC, promotions | `combat-dev` |
|
||
| `src/simulator/crates/mc-magic/`, spells, mana, Archons, enchantments, Ascension | `magic-dev` |
|
||
| `src/simulator/crates/mc-ai/`, AI decisions, difficulty modifiers | `game-ai` |
|
||
| `*.json` data files, `vocabulary.json`, `game.json` | `game-data` |
|
||
| `*.tscn` UI scenes, HUD panels, overlays, menus | `godot-ui` |
|
||
| TileMap, sprites, camera, fog, selection highlight, animation | `godot-renderer` |
|
||
| `public/games/age-of-dwarves/guide/`, `src/packages/guide/`, React, Vite, WASM integration | `guide-web` |
|
||
|
||
## Conventions
|
||
|
||
### TTS Voice (all agents speaking for this project)
|
||
|
||
Every `mcp__speech-synthesis__synthesize` call made by any agent — team lead or teammate — working in this directory MUST use `personality: "ravdess02"`. The user selected this voice from a 6-option trial and it is the project standard. Do NOT default to other personalities, do NOT pick per-agent, do NOT omit the parameter. The personality registry is at `~/.claude/speech-personalities.json`.
|
||
|
||
### GDScript Style
|
||
- snake_case for variables, functions, files
|
||
- PascalCase for classes and nodes
|
||
- Signals use past tense: `unit_moved`, `city_founded`, `tech_researched`
|
||
- Constants in UPPER_SNAKE_CASE
|
||
- Type hints on all function signatures
|
||
|
||
### Class Resolution — Preload Pattern (critical)
|
||
|
||
Godot 4 `class_name` registration is unreliable in autoload context. **Always reference non-autoload classes via `preload()` const**, never by bare class_name:
|
||
|
||
```gdscript
|
||
const CityScript = preload("res://engine/src/entities/city.gd")
|
||
const HexUtilsScript = preload("res://engine/src/map/hex_utils.gd")
|
||
const UnitScript = preload("res://engine/src/entities/unit.gd")
|
||
const TileScript = preload("res://engine/src/map/tile.gd")
|
||
const PlayerScript = preload("res://engine/src/entities/player.gd")
|
||
const GameMapScript = preload("res://engine/src/map/game_map.gd")
|
||
|
||
var unit: UnitScript = UnitScript.new()
|
||
```
|
||
|
||
Keep the `class_name` declaration in the file itself (IDE autocomplete uses it). All runtime references use the preload const.
|
||
|
||
### Entity Class Pattern
|
||
|
||
All game entities (`Unit`, `City`, `Player`, `Building`, `Improvement`) are:
|
||
- `class_name Foo / extends RefCounted` — pure data, no scene/node
|
||
- Rendering handled by separate renderer scripts
|
||
- Logic calls `DataLoader` for type definitions, `EventBus` for state-change signals
|
||
|
||
### Signal Parameters
|
||
|
||
`EventBus` signals pass entity objects as `Variant`, not typed class_name parameters:
|
||
|
||
```gdscript
|
||
# Correct:
|
||
signal unit_moved(unit: Variant, from: Vector2i, to: Vector2i)
|
||
# Wrong — causes type errors in autoload context:
|
||
signal unit_moved(unit: Unit, from: Vector2i, to: Vector2i)
|
||
```
|
||
|
||
### Hex Math
|
||
|
||
All hex coordinate math goes through `HexUtils` static methods — never inline the formulas. `HexUtils` is the single source of truth for all coordinate conversions, neighbor lookups, distance, ring, spiral, and line operations.
|
||
|
||
**Direction-to-edge mapping** — AXIAL direction indices and hex polygon edge indices are NOT the same:
|
||
|
||
```
|
||
Direction: 0(E) 1(NE) 2(NW) 3(W) 4(SW) 5(SE)
|
||
Poly edge: 2 1 0 5 4 3
|
||
```
|
||
|
||
### Data IDs
|
||
- snake_case: `high_elf_heritage`, `chaos_magic`, `fire_elemental`
|
||
- Match the `id` field in JSON data files
|
||
- Reference by ID in code, resolve display name through `ThemeVocabulary`
|
||
|
||
### File Organization
|
||
```
|
||
src/
|
||
game/ — Godot project root (project.godot lives here)
|
||
addons/
|
||
magic_civ_physics/ — compiled GDExtension .so/.dll/.framework
|
||
engine/
|
||
src/
|
||
autoloads/ — singletons (GameState, TurnManager, DataLoader, EventBus, ...)
|
||
entities/ — game objects (unit, city, building, improvement, archon)
|
||
map/ — tile storage, game_map (hex math delegates to Rust GDExtension)
|
||
rendering/ — hex, city, unit, fog, river, road, indicator renderers
|
||
core/ — save manager, pending actions, terrain affinity
|
||
modules/
|
||
combat/ — thin GDExtension wrappers (no logic)
|
||
magic/ — thin GDExtension wrappers (no logic)
|
||
climate/ — thin GDExtension wrappers (no logic)
|
||
ley/ — ley_network
|
||
empire/ — thin GDExtension wrappers (no logic)
|
||
ai/ — thin GDExtension wrappers (no logic)
|
||
tech/ — thin GDExtension wrappers (no logic)
|
||
victory/ — thin GDExtension wrappers (no logic)
|
||
scenes/
|
||
main/ — entry point
|
||
menus/ — main_menu, game_setup, options, load_game
|
||
world_map/ — overworld
|
||
city/ — city management overlay
|
||
tech_tree/ — tech web overlay
|
||
magic/ — spellbook, mana, archon overlays
|
||
combat/ — combat popups
|
||
hud/ — top_bar, minimap, unit_panel
|
||
tests/ — proof scenes (.tscn + .gd co-located)
|
||
tests/
|
||
unit/ — GUT tests by module
|
||
integration/ — end-to-end integration tests
|
||
|
||
simulator/ — Rust workspace (source of truth for all simulation)
|
||
crates/
|
||
mc-core/ — GridState, TileState, BiomeRegistry, hex algorithms
|
||
mc-climate/ — ClimatePhysics, EcologyPhysics, atmosphere, spec evaluator
|
||
mc-mapgen/ — MapGenerator
|
||
mc-combat/ — CombatResolver (stub → full)
|
||
mc-magic/ — SpellSystem, ManaPool, Archons (stub → full)
|
||
mc-economy/ — EmpireEconomy (stub → full)
|
||
mc-city/ — CityGrowth (stub → full)
|
||
mc-happiness/ — happiness pool (stub → full)
|
||
mc-culture/ — CultureAccumulation (stub → full)
|
||
mc-tech/ — TechWeb graph (stub → full)
|
||
mc-ai/ — AI systems (stub → full)
|
||
mc-turn/ — TurnProcessor (stub → full)
|
||
api-wasm/ — wasm-bindgen surface; builds pkg/
|
||
api-gdext/ — godot-rust surface; builds addons/magic_civ_physics/
|
||
pkg/ — wasm-pack output (gitignored except package.json)
|
||
build-wasm.sh
|
||
build-gdext.sh
|
||
|
||
packages/
|
||
engine-ts/ — @magic-civ/engine-ts (types, runner, HexGrid)
|
||
guide/ — @magic-civ/guide-engine (shared UI components, types, utils)
|
||
|
||
resources/ — shared content library
|
||
biomes/
|
||
ecology/
|
||
improvements/
|
||
villages/
|
||
wilds/
|
||
worlds/
|
||
earth/ — primary simulation archetype
|
||
khazad_prime/ — Dwarf homeworld
|
||
|
||
games/
|
||
age-of-dwarves/
|
||
data/ — all JSON content (units/, spells/, techs/, terrain/, ...)
|
||
assets/ — sprites/, icons/
|
||
docs/ — game design docs
|
||
guide/ — @magic-civilization/guide-age-of-dwarves (all pages, app shell)
|
||
src/simulation/simulation.worker.ts — imports from @magic-civ/physics-rs (WASM)
|
||
```
|
||
|
||
**Note:** `src/game/public` is a symlink → `../../public`, making the entire public tree accessible as `res://public/` from within the Godot project (`res://public/resources/`, `res://public/games/`). `src/simulator/public` is the same symlink for Rust test access.
|
||
|
||
## DX Tooling
|
||
|
||
### Forge — Forgejo (NOT Gitea)
|
||
|
||
The self-hosted forge at `http://10.0.0.11:3000/magicciv/magicciv` is **Forgejo**, not Gitea. Probe confirms: `GET /api/v1/version` returns `{"version":"11.0.11+gitea-1.22.0"}` — that `+gitea-` suffix is how Forgejo reports API compatibility with upstream Gitea 1.22.0; pure Gitea would return just `1.22.0`.
|
||
|
||
**Everywhere** in this repo — workflows, docs, runner setup, objective specs, commit messages — use Forgejo terminology:
|
||
|
||
| Correct (Forgejo) | Incorrect (Gitea) |
|
||
|---|---|
|
||
| `.forgejo/workflows/*.yml` | `.gitea/workflows/*.yml` |
|
||
| `forgejo-runner` binary | `act_runner` |
|
||
| `code.forgejo.org/forgejo/runner/releases` | `gitea.com/gitea/act_runner/releases` |
|
||
| `FORGEJO_RUNNER_TOKEN`, `FORGEJO_TOKEN` | `GITEA_RUNNER_TOKEN`, `GITEA_TOKEN` |
|
||
| `forgejo-runner.service` (systemd) | `act_runner.service` |
|
||
| "Forgejo Actions" / "Forgejo release" | "Gitea Actions" / "Gitea release" |
|
||
|
||
Forgejo is drone-compatible (like Gitea Actions) and keeps the `/api/v1/` REST surface identical, so the only things that differ are: branding, runner binary name, runner package URL, and the canonical workflows directory. CI/CD workflows must live under `.forgejo/workflows/`, not `.gitea/workflows/`.
|
||
|
||
### Two-Host Workflow: EDIT host → RUN host
|
||
|
||
Development is split across two hosts by **role**, not by specific machine. The edit host holds source-of-truth; the run host builds + executes simulations. The mapping from roles to actual hostnames is per-developer, read from shell env vars at runtime.
|
||
|
||
| Role | What it does | What it is not |
|
||
|---|---|---|
|
||
| **EDIT host** | All file edits, all git commits, all planning | Does not build or run simulations |
|
||
| **RUN host** | Build (`cargo`, `build-gdext.sh`), test (`cargo test`), headless Godot simulations, GPU compute | Does not hold authoritative source; never `git commit` here |
|
||
|
||
**Rules** (apply regardless of hostname):
|
||
1. **Edits only on EDIT host.** Never `ssh <RUN_HOST>` to edit files. Never `git commit` on RUN host.
|
||
2. **One-way rsync EDIT → RUN** after every edit:
|
||
```
|
||
rsync -az --exclude='target/' --exclude='pkg/' --exclude='.local/' \
|
||
--exclude='addons/*/*.so' --exclude='addons/*/*.dylib' --exclude='addons/*/*.dll' \
|
||
"$PROJECT_ROOT/src/simulator/" "$RUN_HOST:$PROJECT_ROOT_REMOTE/src/simulator/"
|
||
```
|
||
3. **Builds on RUN host only.** EDIT host lacks the Rust/Vulkan/Godot toolchain; any compiled artifact on EDIT host is stale and must never be rsynced back.
|
||
4. **If RUN host's `git log` shows commits not on EDIT host, STOP.** An agent broke the rule. Report to user; don't silently reset — someone's work is there.
|
||
|
||
### Host mapping (per-developer config)
|
||
|
||
The scripts in `tools/` + `scripts/run/` read the role-to-host mapping from environment variables, not hard-coded names:
|
||
|
||
| Variable | Meaning | Example value | Default |
|
||
|---|---|---|---|
|
||
| `AUTOPLAY_HOST` | SSH target for the **RUN host** — cargo/Godot/GPU work (`user@hostname`) | `lilith@apricot.local` | *(unset — required)* |
|
||
| `PROJECT_ROOT` | Repo path on EDIT host | `/Users/natalie/Code/@projects/@magic-civilization` | *(unset — required)* |
|
||
| `PROJECT_ROOT_REMOTE` | Repo path on RUN host | `~/Code/@projects/@magic-civilization` | *(unset — required)* |
|
||
| `REMOTE_RUNNER` | Headless godot wrapper on RUN host | `~/bin/run_ap3.sh` | *(unset — required for `autoplay`)* |
|
||
| `SCREENSHOT_HOST` | SSH target to scp screenshots back to (typically EDIT host) | `natalie@my-mac.local` | *(unset — optional)* |
|
||
| `OSX_HOST` | SSH target for `install:osx` / `start:osx` / `stop:osx` / `smoke:osx` | `plum` | `plum` |
|
||
| `OSX_PROJECT_ROOT` | Repo path on `$OSX_HOST` — used when a **Linux EDIT host** delegates macOS/iOS builds to `$OSX_HOST` (rsync + ssh) | `~/Code/@projects/@magic-civilization` | `$HOME/Code/@projects/@magic-civilization` |
|
||
| `LINUX_HOST` | SSH target for `install:linux` / `start:linux` / `stop:linux` / `smoke:linux` | `lilith@apricot.local` | `lilith@apricot.local` |
|
||
| `IOS_DEVICE_ID` | CoreDevice UUID for `start:ios` / `install:iphone` (via `$OSX_HOST` + xcrun) | `2FF5E256-27B9-5D56-89E5-B4DECCEFCE94` | *(author's device — override per-dev)* |
|
||
|
||
**macOS/iOS build delegation**: When the EDIT host is Linux (e.g. `black.local`), `./run install:osx` and `./run install:iphone` automatically detect via `uname -s`, rsync the source to `$OSX_HOST:$OSX_PROJECT_ROOT`, and recurse into `./run install:<target>` over SSH. macOS EDIT hosts run the build locally. No per-command flag needed — the adaptive delegation is transparent.
|
||
|
||
Set these in your shell rc or a `.env` file that `./run` sources. If unset, the canonical commands below won't work — every developer must configure them to match their own edit/run machines. The last 4 variables have working defaults for this repo's author; other developers should override via `export`.
|
||
|
||
### Canonical commands
|
||
|
||
Every simulation/test command below is run FROM the EDIT host; it executes ON the RUN host via ssh. Never run the `cargo`/`flatpak`/`build-gdext.sh` versions directly on the EDIT host.
|
||
|
||
| Intent | Canonical command (from EDIT host) |
|
||
|---|---|
|
||
| Rust workspace tests | `ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/simulator && cargo test --workspace"` |
|
||
| GPU-feature tests | `ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/simulator && cargo test -p mc-turn --features gpu"` |
|
||
| Single crate | `ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/simulator && cargo test -p <crate>"` |
|
||
| GDExtension build | `ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/simulator && bash build-gdext.sh"` |
|
||
| WASM build (web guide) | `ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/simulator && bash build-wasm.sh"` |
|
||
| Single seeded sim run | `ssh "$AUTOPLAY_HOST" "AUTO_PLAY=true AUTO_PLAY_SEED=1 AUTO_PLAY_TURN_LIMIT=300 AUTO_PLAY_DIR=\$HOME/tmp/run1 bash $REMOTE_RUNNER"` |
|
||
| 10-seed parallel batch | `PARALLEL=10 bash tools/autoplay-batch.sh 10 300 .local/iter/<stamp>` (reads `$AUTOPLAY_HOST`) |
|
||
| GUT unit tests | `ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/game && flatpak run --filesystem=home org.godotengine.Godot --path . --headless -s addons/gut/gut_cmdln.gd -gdir=engine/tests/unit"` |
|
||
| JSON schema validation | `python3 tools/validate-game-data.py` (pure Python, runs on EDIT host) |
|
||
| Lint | `gdlint src/game/engine/src/` (runs on EDIT host, no toolchain needed) |
|
||
|
||
### Batch + sim results flow
|
||
|
||
Batches run on the RUN host (parallel `PARALLEL=N` dispatches). Results scp'd back to `.local/iter/<stamp>/` on the EDIT host for `tools/autoplay-report.py` analysis. The wrapper `tools/autoplay-batch.sh` handles scp automatically — never rsync `.local/` EDIT → RUN; that path is for results-in only.
|
||
|
||
### EDIT-host-only commands (no RUN host needed)
|
||
- **gdtoolkit** (`pip install gdtoolkit`) — `gdlint` + `gdformat`
|
||
- **Python data validators** — `tools/validate-game-data.py`, `tools/autoplay-report.py`, `tools/checklist-report.py`
|
||
|
||
### Build Output Locations
|
||
|
||
All build artifacts land under `.local/build/` (gitignored, per-host, ~25GB typical):
|
||
|
||
| System | Output path | Configured in |
|
||
|---|---|---|
|
||
| Rust (cargo) | `.local/build/rust/` | `src/simulator/.cargo/config.toml` |
|
||
| Godot exports | `.local/build/godot/<version>/<platform>/` | `scripts/run/remote.sh` |
|
||
| WASM (wasm-pack) | `src/simulator/pkg/` | `src/simulator/build-wasm.sh` (wasm-pack default) |
|
||
| TypeScript (vite) | `<pkg>/dist/` per package | each `vite.config.ts` (vite default) |
|
||
|
||
**`src/` is source-only** — never commit build artifacts there. Rust's `target/` used to sit at `src/simulator/target/` and got 65k files (~25GB) committed to git by accident; that's been purged. If you find yourself writing into `src/**/target/`, `src/**/dist/`, or `src/**/build/`, stop — something's misconfigured.
|
||
|
||
### Task Runner (`./run`)
|
||
|
||
Central entry point for dev, export, deploy commands.
|
||
|
||
```bash
|
||
./run play # Launch the game locally
|
||
./run editor # Open Godot editor
|
||
./run lint # gdlint src/game/engine/src/
|
||
./run verify # lint + typecheck + cargo check + tests
|
||
./run test # GUT tests + Rust tests + vitest
|
||
./run screenshot [name] [scene] # Capture + SCP to EDIT host ($SCREENSHOT_HOST)
|
||
./run export [version] # All platforms in parallel
|
||
```
|
||
|
||
### Screenshot & Visual Verification
|
||
|
||
```bash
|
||
./tools/screenshot.sh [name] [scene] [delay]
|
||
```
|
||
|
||
Screenshots are captured in the RUN host's Flatpak user data and SCP'd back to `$SCREENSHOT_HOST:~/Desktop/magic_civ_<name>.png` (typically the EDIT host). Set `$SCREENSHOT_HOST` to match your workstation for review access.
|
||
|
||
### Proof Scenes (`src/game/engine/scenes/tests/`)
|
||
|
||
Self-capturing test scenes. Each phase should have a proof scene that sets up minimal game state, renders claimed features, auto-captures, and quits.
|
||
|
||
### Environment Config (`.env.*`)
|
||
|
||
`EnvConfig` autoload reads `.env` (base) then `.env.development` (overrides) at startup:
|
||
- `.env.production` — `FORCE_DISABLE_FOGOFWAR=false`
|
||
- `.env.development` — `FORCE_DISABLE_FOGOFWAR=true`, `FORCE_UNLIMITED_RESEARCH=true`
|
||
|
||
### DataLoader — File vs Directory Pattern
|
||
|
||
`DataLoader` supports two layouts per data category:
|
||
- **Single file:** `public/games/age-of-dwarves/data/races.json`
|
||
- **Split directory:** `public/games/age-of-dwarves/data/units/` — reads all `.json` files, merges by `id`
|
||
|
||
**Never create a monolithic file that exceeds 500 lines.**
|
||
|
||
### Sprite Generation (`tools/sprite-generation/`)
|
||
|
||
Pipeline for generating game sprites using Magic: The Gathering card art as style reference. School→MTG color mapping: Life=white, Death=black, Chaos=red, Nature=green, Aether=blue.
|
||
|
||
Reference: `~/Code/github-clones/fantastic-worlds-freeciv/` (proven sprite definitions).
|
||
|
||
**Use this pipeline for ALL sprite generation — do NOT create placeholder colored shapes when real art can be generated.**
|
||
|
||
## Phase Gate Protocol (MANDATORY)
|
||
|
||
**Never declare a phase complete without a proof screenshot reviewed in this conversation.**
|
||
|
||
A phase is NOT done until:
|
||
1. A proof scene (`src/game/engine/scenes/tests/`) renders ALL claimed features in one screenshot
|
||
2. The screenshot is captured via `tools/screenshot.sh`
|
||
3. The screenshot is SCP'd to `$SCREENSHOT_HOST:~/Desktop/magic_civ_<phase>_proof.png` (EDIT host for review)
|
||
4. The screenshot is read and reviewed IN THIS CONVERSATION
|
||
5. Every claimed feature is visibly confirmed
|
||
6. The user approves it
|
||
|
||
Code exists ≠ code works. Tests pass ≠ features render. Lint clean ≠ visually correct.
|
||
|
||
## Objective Status Integrity (MANDATORY)
|
||
|
||
`.project/objectives/*.md` frontmatter `status` MUST match what the prose says and what the acceptance bullets verify. This rule exists because the dashboard has previously carried ✅ done items whose own `## Summary` admitted the feature was a 1-line TODO stub, or whose acceptance criteria cited failing tests. That makes the dashboard an unreliable instrument and sends the team chasing shore-up work for items already marked complete.
|
||
|
||
Never set `status: done` if ANY of:
|
||
- The file's own `## Summary` admits open gaps, missing wiring, or remaining work.
|
||
- Any bullet in `## Acceptance` is not demonstrably true (test green, feature visible in a reviewed proof screenshot, data populated, build queue path invoked, etc.).
|
||
- Any test listed under `evidence:` is failing, skipped, or does not exist on disk.
|
||
- The underlying Rust crate is a stub (e.g. `mc-culture/src/lib.rs` = `// TODO`).
|
||
|
||
Allowed transitions:
|
||
- 🔴 stub → 🟡 partial → ✅ done. Never skip 🟡 partial. If you aren't sure every acceptance bullet passes, mark 🟡 partial.
|
||
- Downgrade freely: flip ✅ done back to 🟡 partial the moment a regression or audit exposes a gap. Don't protect the status.
|
||
- `❌ missing` only when no code has been written yet AND no evidence file exists.
|
||
|
||
Ritual when closing an objective to ✅ done:
|
||
1. Read the file's own `## Acceptance` list. For each bullet, cite the exact file path / test name / passing command that proves it, in the commit message or PR body.
|
||
2. If a bullet can't be cited, stay at 🟡 partial and edit the Summary to list the specific remaining blocker.
|
||
3. Run `tools/objectives-report.py` to regenerate `README.md`; verify the totals changed as expected.
|
||
4. If a Phase Gate proof scene is required for the feature (see above), link the screenshot in the evidence list.
|
||
|
||
> "Done" means every acceptance bullet has been verified in this repo, today. Not "code exists." Not "lint passes." Not "a test file exists" — the test has to pass, and the acceptance has to match.
|
||
|
||
## Team Leads — Objective Ownership (MANDATORY)
|
||
|
||
Objectives on the `.project/objectives/` dashboard are owned by **team-leads**, not by individual agent runs or specialists. A team-lead is a persistent ownership role over a **bundle of related objectives** that outlives any single PR, session, or sprint.
|
||
|
||
### Why team-leads exist, separate from `.claude/agents/`
|
||
|
||
The project already has craft-level specialists in `.claude/agents/` (`godot-ui`, `combat-dev`, `game-systems`, `game-ai`, …). Those are **task-level executors** — they do one slice of work and return. Team-leads are **strategic owners** — they decide how a bundle of objectives fits together, which specialists to dispatch, and when to declare the bundle done. A team-lead may employ many specialists over many sessions; a specialist never owns an objective.
|
||
|
||
### Directory: `.project/team-leads/`
|
||
|
||
One file per team-lead at `.project/team-leads/<kebab-case-id>.md` with this frontmatter:
|
||
|
||
```yaml
|
||
---
|
||
id: <kebab-case-id> # must match filename
|
||
name: <Display Name>
|
||
specialization: <one-line concern>
|
||
objectives: [<objective-id>, ...]
|
||
---
|
||
|
||
## Mandate
|
||
## Owned surface — file paths / crates the lead may modify
|
||
## Boundaries — what the lead reads but does NOT modify
|
||
## Escalation — when to hand off to another owner
|
||
```
|
||
|
||
See `.project/team-leads/README.md` for the full schema and current roster.
|
||
|
||
### Objective frontmatter — `owner:` field
|
||
|
||
Each objective file may declare an optional `owner:` that matches the basename of a team-lead file:
|
||
|
||
```yaml
|
||
---
|
||
id: p0-01
|
||
title: Wire MCTS into gameplay AI
|
||
priority: p0
|
||
status: partial
|
||
scope: game1
|
||
owner: warcouncil # ← links to .project/team-leads/warcouncil.md
|
||
updated_at: 2026-04-17
|
||
evidence: [...]
|
||
---
|
||
```
|
||
|
||
`tools/objectives-report.py` validates that `.project/team-leads/<owner>.md` exists whenever `owner:` is set, then renders the name in the dashboard's new **Owner** column. Unowned objectives render `—`.
|
||
|
||
### Objective-claiming protocol
|
||
|
||
When an agent, team-lead, or contributor takes responsibility for an objective, they **claim** it by setting `owner:`. Rules:
|
||
|
||
1. **Honor existing claims.** If an objective already has an `owner:` that matches a live team-lead file, do NOT silently overwrite it. Open a handoff in `.project/handoffs/` first, or ask the user.
|
||
2. **Unowned objectives are first-come.** An objective with no `owner:` may be claimed by any team-lead whose Mandate / Owned surface fits the work. Update the frontmatter + run the dashboard generator in the same commit.
|
||
3. **Priority order when choosing what to claim next.** Dashboard priority tier is authoritative: P0 blockers before P1 ship-readiness before P2 polish. Within a tier, prefer 🟡 partial over ❌ missing (partials have more context); prefer objectives whose acceptance criteria you can verify in your current working session over ones that need cross-team coordination.
|
||
4. **One team-lead can own multiple objectives.** Bundle related work under one owner (e.g. `warcouncil` owns the entire AI triplet p0-01 + p0-02 + p0-20). This keeps the strategy coherent.
|
||
5. **Out-of-scope `⚫ oos` objectives (Game 2) stay unowned.** They're deferred scope; claiming them before Game 1 ships is a violation of the "do NOT ship Game 2 features into Game 1" scope rule.
|
||
6. **Closing a claim.** When all owned objectives reach ✅ done, the team-lead file stays in place — it's a historical record of ownership, not a task ticket. Do not delete it.
|
||
|
||
### Ritual when claiming
|
||
|
||
1. Create the team-lead file at `.project/team-leads/<id>.md` if it doesn't already exist.
|
||
2. Add `owner: <id>` to each claimed objective's frontmatter.
|
||
3. Run `python3 tools/objectives-report.py` to regenerate the dashboard.
|
||
4. Commit the team-lead file, the objective edits, and the regenerated dashboard **together** — never leave a dangling `owner:` pointing at a missing team-lead file.
|
||
5. The validator in `tools/objectives-report.py` will refuse to regenerate the dashboard if any `owner:` reference is dangling.
|
||
|
||
## Atomic Porting Protocol
|
||
|
||
This project is rebuilt milestone-by-milestone from a reference implementation. **Only port what the current milestone requires.**
|
||
|
||
- **Data files**: only copy JSON files that the current milestone's code actually loads — not the full 99-file set "for completeness"
|
||
- **Engine code**: only port systems listed in the current milestone's task list — don't pull in combat when building climate
|
||
- **Scenes**: only create scenes needed for the current phase's proof screenshot
|
||
- **Tests**: only port tests for systems that exist in this repo — a test for code that hasn't been ported yet is dead weight
|
||
- If a file from the reference doesn't have a consumer in this repo yet, it doesn't belong here yet
|
||
- The milestone task lists in `.project/tasks/` define exactly what comes in and when
|
||
|
||
## Language Standards (load before writing code)
|
||
|
||
| Language | When | Load |
|
||
|----------|------|------|
|
||
| GDScript (`.gd`) | Any GDScript authoring | `~/.claude/instructions/godot-code-standards.md` |
|
||
| Rust (`.rs`) | Any `src/simulator/` work | `~/.claude/instructions/rust-code-standards.md` |
|
||
| TypeScript (`.ts`, `.tsx`) | Any guide/package TS work | `~/.claude/instructions/typescript-code-standards.md` |
|
||
| Python (`.py`) | Tools, scripts, data pipelines | `~/.claude/instructions/python-code-standards.md` |
|
||
|
||
Hooks enforce these standards automatically on Write/Edit — they are not optional.
|
||
|
||
## Safety Rules
|
||
- Never hardcode theme-specific strings in engine code
|
||
- Never hardcode asset paths — always use `ThemeAssets.resolve()`
|
||
- `GameState` must support multiple map layers (future Ethereal Plane)
|
||
- All system state changes emit signals via `EventBus`
|
||
- Building/unit effects are data-driven from JSON — don't hardcode behavior
|
||
- Always call `DataLoader.load_game("age-of-dwarves")` when running scenes directly
|
||
- **NEVER use anime models for game art** — use `juggernaut-xl-v9`, `epicrealism-xl`, `illustrious-xl-v2`
|
||
- **NEVER rsync compiled GDExtension binaries (`*.so`, `*.dll`, `*.dylib`) from EDIT host → RUN host.** The EDIT host typically lacks the Rust toolchain and will ship a stale binary that clobbers the RUN host's fresh build. Use `rsync --exclude='addons/*/*.so' --exclude='addons/*/*.dylib' --exclude='addons/*/*.dll'` OR rely on `.gitignore` (pass `--filter=':- .gitignore'` — rsync does NOT respect gitignore by default). After rsyncing Rust source, always rebuild on the RUN host: `ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/simulator && bash build-gdext.sh"`. Symptom of this bug: `src/simulator/crates/*.rs` has new code but batch runs show old behavior (e.g. `FOOD_PER_POP=2.0` in a binary whose source says `1.5`).
|
||
- **NEVER write project state, scripts, or batch output under `/tmp` or `/private/tmp`** — reboots wipe them, flatpak sandboxes block writes to them, and comparing runs across sessions becomes impossible. Canonical locations:
|
||
- Shell scripts/runners → `scripts/` (in-repo, tracked) or `$HOME/bin/` (persistent per-host)
|
||
- Batch/iteration outputs → `.local/batches/` (in-repo, gitignored) or `$HOME/tmp/` (persistent per-host)
|
||
- Per-iteration diagnostic dirs → `.local/iter/` (in-repo, gitignored)
|
||
- Build artifacts (cargo target, Godot exports) → `.local/build/{rust,godot}/` (in-repo, gitignored)
|
||
- RUN host paths → `$PROJECT_ROOT_REMOTE/.local/...` (mirror of EDIT host repo layout)
|
||
- ONLY `/tmp` is acceptable for: genuine inter-process pipes (mkfifo), socket activation (.sock files), or Godot internal scratch the engine itself puts there. If you find yourself writing `/tmp/mc_*` or `/tmp/run_*`, STOP — use one of the paths above.
|