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.
**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.
**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.
- **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.
> **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.**
- **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`**
- **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.
- 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:
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:
**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.
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.
- **NEVER rsync compiled GDExtension binaries (`*.so`, `*.dll`, `*.dylib`) from macOS to apricot.** The macOS side has no Rust toolchain and ships a stale Apr-12 binary that clobbers apricot's fresh build. Use `rsync --exclude='addons/magic_civ_physics/*.so'` OR rely on the `.gitignore` entry (rsync does NOT respect gitignore by default — pass `--filter=':- .gitignore'`). Always rebuild via `ssh lilith@apricot.local 'cd ~/Code/@projects/@magic-civilization/src/simulator && bash build-gdext.sh'` after rsyncing Rust source changes. 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).
- Remote apricot paths → `$HOME/Code/@projects/@magic-civilization/.local/...` (mirror of 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.