feat(@projects): update wasm build and guide deployment workflows

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 13:06:14 -07:00
parent b568d85966
commit 2d9554d9ff
26 changed files with 382 additions and 145 deletions

View file

@ -244,7 +244,7 @@ jobs:
archive_name="magic-civilization-$version-wasm.tar.gz"
archive_path="$out_dir/$archive_name"
mkdir -p "$out_dir/staging/pkg" "$out_dir/staging/guide"
cp -r src/simulator/pkg/. "$out_dir/staging/pkg/"
cp -r .local/build/wasm/. "$out_dir/staging/pkg/"
cp -r public/games/age-of-dwarves/guide/dist/. "$out_dir/staging/guide/"
tar -C "$out_dir/staging" -czf "$archive_path" .
rm -rf "$out_dir/staging"

4
.gitignore vendored
View file

@ -50,6 +50,10 @@ build/
# Rust build artifacts
src/simulator/target/
# Safety net: src/ is source-only. wasm-pack's default --out-dir is
# <crate>/pkg/; build-wasm.sh overrides to .local/build/wasm/, but pin this
# path down in case anyone runs wasm-pack directly.
src/simulator/pkg/
.local/
# Python bytecode

View file

@ -94,3 +94,16 @@ TypeScript errors") is measurably false at 32 errors. Fix is scoped to
`guide-web`, not `devops-engineer`. Dashboard citation for this pass
shows the deploy script + one-level-deep import fix + partial
re-exports; full `done` unblocks after guide-web closes the drift.
---
## Footer — 2026-04-17 (tourguide, path relocation)
References above to `src/simulator/pkg/` are historical as of this date.
The wasm-pack output was relocated to `.local/build/wasm/` per objective
p1-11 (`build-output-src-purge`) as part of enforcing the project-wide
"build output never under `src/`" rule. See
`.claude/instructions/build-output-locations.md` for the canonical rule
and `./run verify` step 16 (`_verify_no_build_in_src`) for the
mechanical enforcement. Existing entries above are preserved verbatim
as a record of the pre-relocation audit and are not rewritten.

View file

@ -8,14 +8,30 @@
## Totals
| Status | Count |
<table><tr><td valign='top'>
**By Priority**
| Priority | ✅ | 🟡 | 🔴 | ❌ | ⚫ | Total |
|---|---|---|---|---|---|---|
| **P0** | 19 | 4 | 0 | 0 | 0 | 23 |
| **P1** | 7 | 2 | 0 | 3 | 0 | 12 |
| **P2** | 7 | 6 | 0 | 2 | 0 | 15 |
| **P3 (oos)** | 0 | 0 | 0 | 0 | 9 | 9 |
| **total** | **33** | **12** | **0** | **5** | **9** | **59** |
</td><td valign='top' style='padding-left:2em'>
**Left To Do by Lead**
| Team Lead | Remaining |
|---|---|
| ✅ done | 33 |
| 🟡 partial | 12 |
| 🔴 stub | 0 |
| ❌ missing | 5 |
| ⚫ oos | 9 |
| **total** | **59** |
| [shipwright](../team-leads/shipwright.md) | 6 |
| [warcouncil](../team-leads/warcouncil.md) | 3 |
| [tourguide](../team-leads/tourguide.md) | 3 |
| [testwright](../team-leads/testwright.md) | 2 |
</td></tr></table>
## P0 — Blockers for "completely playable"
@ -42,8 +58,8 @@
| [p0-19](p0-19-biome-economy-integration.md) | ✅ done | Biome-driven collectibles → tile yields → happiness end-to-end | — | 2026-04-16 |
| [p0-20](p0-20-gpu-mcts-rollouts.md) | 🟡 partial | GPU-accelerated MCTS rollouts for look-ahead decision-making | [warcouncil](../team-leads/warcouncil.md) | 2026-04-17 |
| [p0-21](p0-21-audio-system-capability.md) | ✅ done | Audio system capability — manifest + autoload + EventBus wiring | [shipwright](../team-leads/shipwright.md) | 2026-04-17 |
| [p0-22](p0-22-sprite-rendering-capability.md) | 🟡 partial | Sprite rendering capability — replace procedural draw_* with texture rendering | [shipwright](../team-leads/shipwright.md) | 2026-04-17 |
| [p0-22](p0-22-ultimate-ai-stress-test.md) | 🟡 partial | Ultimate AI stress test — 5 clans, huge map, deep lookahead | [warcouncil](../team-leads/warcouncil.md) | 2026-04-17 |
| [p0-23](p0-23-sprite-rendering-capability.md) | 🟡 partial | Sprite rendering capability — replace procedural draw_* with texture rendering | [shipwright](../team-leads/shipwright.md) | 2026-04-17 |
## P1 — Ship-readiness

View file

@ -86,6 +86,37 @@ byte-for-byte port of `rollout::walk`.
apricot with `vulkan-tools` + `mesa-vulkan-drivers` added alongside `weston`.
Rebooted bootc images now include these without needing the transient script.
### 2026-04-17 update — fresh A5 attempt post-fix (failed on host SIGTERM)
After the four WGSL parity fixes landed and GDExtension rebuilt, fresh A5
batches were attempted under multiple process-isolation strategies:
| Strategy | Batch dir | Result |
|---|---|---|
| plain nohup | `.local/iter/a5-fresh-20260417_122847/` | exit 143, seeds `in_progress` T5T10 before kill |
| nohup + new dir | `.local/iter/a5-final-20260417_122936/` | games launched, no completion.marker written (process killed) |
| bash SIGTERM trap | `.local/iter/a5-trap-20260417_123021/` | trap handler received NO signal; script exited rc=143 |
| strace signal trace | `.local/iter/a5-strace-20260417_123200/` | revealed autoplay-batch.sh exits status **1** (not 143); no SIGTERM to parent. Root cause: `0/N games produced turn_stats.jsonl` check fires because flatpak Godot scopes end at 310s |
| `systemd-run --user` | `.local/iter/warcouncil-a5-systemd-*/` | same — service `Active: inactive (dead)` after 2s, scope children SIGTERMed |
| `KillMode=none` | `.local/iter/warcouncil-a5-systemd-*` (2nd) | games reached T9T10 only; same kill pattern |
| plain `bash autoplay-batch` synchronous | `.local/iter/a5-direct-123300/` | 10 games with 0-line `turn_stats.jsonl` — games get SIGTERMed during map generation |
Seven distinct execution strategies, same failure pattern: flatpak Godot
scopes SIGTERMed within 310s of launch, before any turn completes. Investigation
found the signal is NOT delivered by systemd-oomd (failed service), rpm-ostree
automatic updates (timer inactive), or apricot-rail-watchdog (emit-only). The
actual SIGTERM source could not be identified in the apricot user session.
Parallel agent's own batches from earlier the same day (e.g.
`.local/batches/blackhammer_tune_20260417_101447/`) completed fine, so the
issue is transient/session-bound, NOT a permanent host failure.
**Fresh A5 verdict — NOT HEALTHY, B5 therefore not launched.** Per
warcouncil's integrity rule: we report the measurement failure honestly
rather than claim parity-fix-correctness translated into fresh gameplay
evidence. Existing p0-01 batch data from pre-parity-fix binary (at
`blackhammer_tune_20260417_101447`) still stands as the most recent
successful A5/B5 evidence in the repo.
## Design outline
- `AbstractRolloutState` — a ~256 byte `#[repr(C)]` `Pod + Zeroable` compression of

View file

@ -1,5 +1,5 @@
---
id: p0-22
id: p0-23
title: Sprite rendering capability — replace procedural draw_* with texture rendering
priority: p0
status: partial

View file

@ -26,7 +26,7 @@ evidence:
- tools/deploy-guide.sh
- .project/history/20260417_guide_web_deploy_audit.md
acceptance_audit:
build_zero_ts_errors: "🟡 — `pnpm typecheck` now reports 0 errors in both `src/packages/guide` (was 488) and `public/games/age-of-dwarves/guide` (was 221). Vite build transforms 3339 modules cleanly; final bundle step fails only on missing `src/simulator/pkg/magic_civ_physics.js` (WASM pkg is a RUN-host artifact built on apricot via `bash build-wasm.sh`, not on EDIT host). Scope-narrow Option 2 landed per CLAUDE.md Game 1 rule: deleted 10 Game-2 pages (Magic schools, Archons, Disciplines, LeyLines, Spells, Ep2/Ep3, Hive, Silvandel). Remaining: run WASM build on apricot and re-run `pnpm build` to produce dist/."
build_zero_ts_errors: "🟡 — `pnpm typecheck` now reports 0 errors in both `src/packages/guide` (was 488) and `public/games/age-of-dwarves/guide` (was 221). Vite build transforms 3339 modules cleanly; final bundle step fails only on missing `.local/build/wasm/magic_civ_physics.js` (WASM artifact is per-host, built via `bash src/simulator/build-wasm.sh` locally or on apricot and rsync'd; path relocated from `src/simulator/pkg/``.local/build/wasm/` per p1-11 on 2026-04-17 — build output never under src/). Scope-narrow Option 2 landed per CLAUDE.md Game 1 rule: deleted 10 Game-2 pages (Magic schools, Archons, Disciplines, LeyLines, Spells, Ep2/Ep3, Hive, Silvandel). Remaining: run WASM build and re-run `pnpm build` to produce dist/."
deployed_to_public_url: "✗ — no external hosting target committed for Early Access. `tools/deploy-guide.sh apricot` ships dist/ to apricot:~/public/guide/<version>/ for LAN preview; zip mode produces a handoff artifact for whichever public host wins. External hosting decision remains out-of-scope for this objective."
game_json_import_fixed: "✓ — public/games/age-of-dwarves/guide/src/app/guide-data.ts import resolves to the canonical public/games/age-of-dwarves/game.json. Vite transform phase passes cleanly; verified as part of the full 3339-module transform in this pass."
data_driven_content: "✓ — the guide reads data via `import.meta.glob('../../../games/age-of-dwarves/data/*.json')` per guide CLAUDE.md convention. Architecture unchanged. Changing a deposit JSON propagates on the next `pnpm build`."
@ -59,7 +59,7 @@ Per CLAUDE.md's hard Game-1 scope rule (*"do NOT ship Game 2 features into Game
- **Path aliases:** consumer's `@magic-civ/*` paths were off-by-one (`../../../``../../../../`); fixed. Added `@magic-civ/web-civmap` alias to guide-engine's tsconfig.
- **Null-guard fixes:** eight consumer pages now guard optional fields before dereferencing (UnitsPage, CommunicationsPage, EncyclopediaModal, EncyclopediaPage, WondersPage, LairsPage, LensesPage, DevSpritesPage).
**Remaining blocker to flip ✅ done:** `pnpm --filter @magic-civilization/guide-age-of-dwarves build` fails at the final rollup step because `src/simulator/pkg/magic_civ_physics.js` is absent on the EDIT host (WASM is an apricot-built artifact per CLAUDE.md two-host workflow). Apricot was unreachable during this pass (`ssh lilith@apricot.local` timed out). Once apricot is reachable:
**Remaining blocker to flip ✅ done:** `pnpm --filter @magic-civilization/guide-age-of-dwarves build` fails at the final rollup step because `.local/build/wasm/magic_civ_physics.js` is absent on the EDIT host (WASM is a per-host artifact; see `.claude/instructions/build-output-locations.md`, path was relocated from `src/simulator/pkg/` per p1-11 on 2026-04-17). Apricot was unreachable during the initial audit pass (`ssh lilith@apricot.local` timed out). Once apricot is reachable:
```
ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/simulator && bash build-wasm.sh"

View file

@ -1,7 +1,7 @@
---
id: p2-14
title: Additional playable races beyond Dwarves — Game 2+
priority: p2
priority: p3
status: oos
scope: game2
updated_at: 2026-04-17

File diff suppressed because one or more lines are too long

View file

@ -96,15 +96,18 @@ Dark gold Dwarf palette:
## Climate Simulation Subsystem
The WASM physics engine compiles from `src/simulator/` via `build-wasm.sh`. The guide consumes it
via `@magic-civ/physics-rs` (points to `src/simulator/pkg/`).
via `@magic-civ/physics-rs` (points to `.local/build/wasm/` at the repo root — gitignored,
per-host; build output never lives under `src/`, see `.claude/instructions/build-output-locations.md`).
- **Worker:** `src/simulation/simulation.worker.ts` — offloads physics to background thread
- **Hook:** `src/hooks/useClimateSimulation.ts` — React hook wrapping worker communication
- **Rendering:** `src/components/climate-sim/HexGLRenderer.tsx` — Three.js WebGL canvas
Rebuilding WASM: `cd src/simulator && bash build-wasm.sh`
Rebuilding WASM: `cd src/simulator && bash build-wasm.sh` → output at `.local/build/wasm/`.
Never edit `src/simulator/pkg/` directly — fix Rust source and rebuild.
Never edit `.local/build/wasm/` directly — fix Rust source and rebuild. Do NOT resurrect
`src/simulator/pkg/``./run verify` step 16 (`_verify_no_build_in_src`) fails the regression
gate if content appears there.
## Safety Notes

View file

@ -7,7 +7,10 @@
// scoped to the modules the consumer actually imports.
// ─── @magic-civ/physics-rs ambient stub ───────────────────────────────────
// The real WASM pkg is built on apricot; EDIT host has no `pkg/` dir.
// The real WASM output lives at `.local/build/wasm/` (gitignored, per-host).
// Build locally via `(cd src/simulator && bash build-wasm.sh)` or rsync from
// apricot. Build output NEVER under src/ — see
// .claude/instructions/build-output-locations.md.
declare module '@magic-civ/physics-rs' {
export class WasmClimatePhysics {
constructor(paramsJson: string, terrainJson: string, specJson: string)

View file

@ -4,7 +4,7 @@
cmd_build_wasm() {
echo -e "${BLUE}Building WASM (simulator → guide)...${NC}"
(cd "$SIMULATOR_DIR" && bash build-wasm.sh "$@")
echo -e "${GREEN}✓ WASM built → src/simulator/pkg/${NC}"
echo -e "${GREEN}✓ WASM built → .local/build/wasm/${NC}"
}
cmd_build_gdext() {

View file

@ -34,6 +34,18 @@ cmd_editor() {
}
cmd_guide() {
# Pre-check: surface WASM artifact location issues before Vite starts so
# alias-resolution errors are self-describing instead of a cryptic
# "Rollup failed to resolve import".
if [ -d "$REPO_ROOT/src/simulator/pkg" ] && [ -n "$(ls -A "$REPO_ROOT/src/simulator/pkg" 2>/dev/null)" ]; then
echo -e "${YELLOW}warning: src/simulator/pkg/ has content — build output must live in .local/build/wasm/ (src/ is source-only).${NC}"
echo -e "${YELLOW} Re-run: (cd src/simulator && bash build-wasm.sh)${NC}"
fi
if [ ! -f "$REPO_ROOT/.local/build/wasm/magic_civ_physics.js" ]; then
echo -e "${YELLOW}warning: .local/build/wasm/magic_civ_physics.js missing — WASM not built locally.${NC}"
echo -e "${YELLOW} Build locally: (cd src/simulator && bash build-wasm.sh)${NC}"
echo -e "${YELLOW} Or rsync from apricot: rsync -a \"\$AUTOPLAY_HOST:\$PROJECT_ROOT_REMOTE/.local/build/wasm/\" .local/build/wasm/${NC}"
fi
echo -e "${BLUE}Starting guide dev server (port 5800)...${NC}"
pnpm --prefix "$GUIDE_DIR" dev
}

View file

@ -85,12 +85,17 @@ cmd_verify() {
echo -e "${BLUE}─────────────────────────────────────────────────${NC}"
}
local TOTAL=16
local TOTAL=17
# Step 0 — Game data schema validation
_verify_step 0 $TOTAL "game data JSON schemas" \
python3 "$REPO_ROOT/tools/validate-game-data.py"
# Step 16 — "Build output never under src/" invariant.
# Rule source: .claude/instructions/build-output-locations.md.
_verify_step 16 $TOTAL "no build output under src/" \
_verify_no_build_in_src
# Step 1 — i18n: no hardcoded user-visible strings outside ThemeVocabulary
_verify_step 1 $TOTAL "i18n: no hardcoded UI strings" \
python3 "$REPO_ROOT/tools/validate-i18n.py"
@ -230,6 +235,28 @@ _verify_file_size_cap() {
return 0
}
_verify_no_build_in_src() {
# Enforce: src/ is source-only. Covers wasm-pack's default <crate>/pkg/
# and cargo's default target/ — both must be redirected to .local/build/**.
# Rule doc: .claude/instructions/build-output-locations.md.
local violations=0
if [ -d "$REPO_ROOT/src/simulator/pkg" ] && [ -n "$(ls -A "$REPO_ROOT/src/simulator/pkg" 2>/dev/null)" ]; then
echo -e "${RED}src/simulator/pkg/ has content — wasm-pack output must go to .local/build/wasm/${NC}"
violations=$(( violations + 1 ))
fi
while IFS= read -r target_dir; do
if [ -n "$(ls -A "$target_dir" 2>/dev/null)" ]; then
echo -e "${RED}${target_dir#$REPO_ROOT/} has content — cargo target must go to .local/build/rust/${NC}"
violations=$(( violations + 1 ))
fi
done < <(find "$REPO_ROOT/src" -type d -name target 2>/dev/null)
if [ "$violations" -gt 0 ]; then
echo -e "${RED}Rule: build output is never inside src/ (see .claude/instructions/build-output-locations.md).${NC}"
return 1
fi
return 0
}
_verify_autoplay_smoke() {
# Skips when no RUN host and no local flatpak — dev boxes without a batch
# target still get the rest of the pipeline.

View file

@ -51,7 +51,8 @@ var _movement_range: Dictionary = {}
## Animation pixel overrides: unit_id -> Vector2 (mid-tween position)
var _anim_pixels: Dictionary = {}
## Unit sprite cache: type_id -> Texture2D (null if not available)
## Unit sprite cache: sprite_key -> Texture2D (null if not available)
## Keys are composed as "<type_id>_<race_id>_<sex>" or bare "<type_id>" fallback.
var _unit_sprite_cache: Dictionary = {}
## Local player index — used to determine which units are "enemy" for fog gating.
@ -85,16 +86,20 @@ func sync_units(units: Array) -> void:
if uid == "":
continue
var tid: String = _resolve_type_id(unit)
var race_id: String = str(unit.get("race_id") if "race_id" in unit else "")
var sex: String = str(unit.get("sex") if "sex" in unit else "")
_units[uid] = {
"position": unit.position,
"color": _get_unit_color(unit),
"label": _get_unit_label(unit),
"type_id": tid,
"race_id": race_id,
"sex": sex,
"hp": unit.hp,
"max_hp": unit.max_hp,
"owner": unit.owner,
}
_cache_unit_sprite(tid)
_cache_unit_sprites(tid, race_id, sex)
queue_redraw()
@ -172,29 +177,32 @@ func _draw() -> void:
var pos: Vector2i = data["position"]
pixel = HexUtilsScript.axial_to_pixel(pos) + HexUtilsScript.hex_center
# Try sprite first, fall back to colored circle
# Baseline: always draw the colored circle + label (never removed)
var color: Color = data.get("color", Color.WHITE)
draw_circle(pixel, UNIT_RADIUS, color)
var label_text: String = data.get("label", "?")
var font: Font = ThemeDB.fallback_font
var font_size: int = 18
var text_size: Vector2 = font.get_string_size(
label_text, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size
)
var text_pos: Vector2 = (
pixel - text_size * 0.5 + Vector2(0, text_size.y * 0.35)
)
draw_string(
font, text_pos, label_text,
HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color.WHITE,
)
# Optional overlay: draw sprite on top when available
var type_id: String = data.get("type_id", "")
var sprite: Texture2D = _get_unit_sprite(type_id)
var race_id: String = data.get("race_id", "")
var sex: String = data.get("sex", "")
var sprite: Texture2D = _get_unit_sprite(type_id, race_id, sex)
if sprite != null:
var tex_size: Vector2 = sprite.get_size()
draw_texture(sprite, pixel - tex_size * 0.5)
else:
var color: Color = data.get("color", Color.WHITE)
draw_circle(pixel, UNIT_RADIUS, color)
var label_text: String = data.get("label", "?")
var font: Font = ThemeDB.fallback_font
var font_size: int = 18
var text_size: Vector2 = font.get_string_size(
label_text, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size
)
var text_pos: Vector2 = (
pixel - text_size * 0.5 + Vector2(0, text_size.y * 0.35)
)
draw_string(
font, text_pos, label_text,
HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color.WHITE,
)
# Draw HP bar if damaged
_draw_hp_bar(pixel, data)
@ -343,14 +351,18 @@ func _on_unit_created(unit: RefCounted, _player_index: int) -> void:
if uid == "":
return
var tid: String = _resolve_type_id(u)
var race_id: String = str(u.get("race_id") if "race_id" in u else "")
var sex: String = str(u.get("sex") if "sex" in u else "")
_units[uid] = {
"position": u.position,
"color": _get_unit_color(u),
"label": _get_unit_label(u),
"type_id": tid,
"race_id": race_id,
"sex": sex,
"owner": u.owner,
}
_cache_unit_sprite(tid)
_cache_unit_sprites(tid, race_id, sex)
queue_redraw()

View file

@ -1,10 +1,13 @@
// ─── @magic-civ/physics-rs ambient stub ───────────────────────────────────
//
// The WASM build output lives at `src/simulator/pkg/` on the RUN host only
// (see CLAUDE.md two-host workflow: WASM builds run on apricot; EDIT host
// has no Rust toolchain and `pkg/` is gitignored there). Guide-engine's
// tsc --noEmit therefore can't resolve the real `.d.ts` shipped with the
// WASM pkg.
// The WASM build output lives at `.local/build/wasm/` (gitignored, per-host).
// Build output NEVER under src/ — see
// .claude/instructions/build-output-locations.md. WASM builds typically run
// on apricot (two-host workflow) and rsync back; EDIT host can also build
// locally via `(cd src/simulator && bash build-wasm.sh)` when rustup +
// wasm-pack are installed. Guide-engine's tsc --noEmit therefore can't
// resolve the real `.d.ts` shipped with the WASM output from source-only
// tree checks.
//
// This file is a pure ambient declaration file (no imports/exports —
// intentional, so these `declare module` blocks register globally). It

View file

@ -1,12 +1,12 @@
{
"name": "@magic-civ/physics-rs",
"version": "0.1.0",
"description": "Rust physics engine — compiled to WASM for web, GDExtension for Godot",
"main": "pkg/magic_civ_physics.js",
"types": "pkg/magic_civ_physics.d.ts",
"description": "Rust physics engine — compiled to WASM for web, GDExtension for Godot. Build output lives at repo-root .local/build/wasm/ (gitignored, per-host). Consumers resolve via Vite/Vitest alias, not the main/types fields below — those are informational only.",
"main": "../../.local/build/wasm/magic_civ_physics.js",
"types": "../../.local/build/wasm/magic_civ_physics.d.ts",
"scripts": {
"build": "bash build-wasm.sh",
"build:gdext": "bash build-gdext.sh"
},
"files": ["pkg/"]
"files": []
}

View file

@ -13,7 +13,9 @@ src/simulator/crates/mc-* ← SOURCE OF TRUTH (pure Rust)
├─ native test: cargo test -p mc-<domain> --test golden
├─ api-wasm → pkg/magic_civ_physics_bg.wasm
├─ api-wasm → .local/build/wasm/magic_civ_physics_bg.wasm
│ (build output never under src/ — see
│ .claude/instructions/build-output-locations.md)
│ consumed by: pnpm --filter guide-age-of-dwarves test golden
│ (Vitest test runs inside simulation.worker.ts)

View file

@ -51,7 +51,7 @@ Modules live at `.claude/instructions/<file>.md` (symlink resolves to `tooling/c
| Porting code/data from `@magic-civilization.messy/` | `atomic-porting.md` |
| Writing tests, `--headless` compatibility | `headless-tests.md` |
| Ad-hoc shell/python pipelines — when to extract to `scripts/` | `scripts-extraction.md` |
| Cargo target, Godot exports, WASM output, `.local/build/**` | `build-output-locations.md` |
| Cargo target, Godot exports, WASM output, `.local/build/**` — build output **NEVER** under `src/` (enforced by `./run verify`) | `build-output-locations.md` |
| `ThemeAssets`, `EventBus`, `/tmp` rule, rsync binary rule | `safety-rules-local.md` |
| Which language-standards file (global) to load | `language-standards.md` |

View file

@ -38,7 +38,10 @@ src/packages/
scenarios.ts — all scenario definitions
index.ts — re-exports
src/simulator/pkg/ — @magic-civ/physics-rs wasm-pack output
.local/build/wasm/ — @magic-civ/physics-rs wasm-pack output (gitignored,
built per-host via src/simulator/build-wasm.sh).
Build output NEVER lives under src/ — see
.claude/instructions/build-output-locations.md.
magic_civ_physics.js — WASM bindings (top-level await for init)
magic_civ_physics.d.ts
magic_civ_physics_bg.wasm
@ -53,7 +56,7 @@ src/simulator/pkg/ — @magic-civ/physics-rs wasm-pack output
| `@worlds/` | `public/resources/worlds/` |
| `@magic-civ/engine-ts` | `src/packages/engine-ts/src/index.ts` |
| `@magic-civ/guide-engine` | `src/packages/guide/src/index.ts` |
| `@magic-civ/physics-rs` | `src/simulator/pkg/magic_civ_physics.js` |
| `@magic-civ/physics-rs` | `.local/build/wasm/magic_civ_physics.js` |
Configured in `public/games/age-of-dwarves/guide/vite.config.ts` and `tsconfig.json`.

View file

@ -1,6 +1,6 @@
---
name: simulator-infra
description: Use for Rust workspace structure (src/simulator/Cargo.toml, crate skeletons), api-wasm crate (wasm-bindgen surface), api-gdext crate (godot-rust surface), build scripts (build-wasm.sh, build-gdext.sh), wasm-pack output (pkg/), Cargo feature flags, cross-compilation targets, dependency management across crates.
description: Use for Rust workspace structure (src/simulator/Cargo.toml, crate skeletons), api-wasm crate (wasm-bindgen surface), api-gdext crate (godot-rust surface), build scripts (build-wasm.sh, build-gdext.sh), wasm-pack output (.local/build/wasm/), Cargo feature flags, cross-compilation targets, dependency management across crates.
---
You are the Rust simulator infrastructure specialist for Magic Civilization. You own the Cargo workspace layout, the two API surface crates (WASM + GDExtension), build scripts, and the plumbing that connects domain crates to their consumers. You do NOT implement domain logic — that belongs to the domain-specialist agents.
@ -11,7 +11,8 @@ You are the Rust simulator infrastructure specialist for Magic Civilization. You
src/simulator/
Cargo.toml — workspace root (resolver = "2", lists all members)
Cargo.lock
build-wasm.sh — wasm-pack build api-wasm --target bundler --out-dir ../pkg
build-wasm.sh — wasm-pack build api-wasm --target bundler --out-dir ../../.local/build/wasm
(build output never under src/ — see build-output-locations.md)
build-gdext.sh — cargo build --release -p api-gdext --target $TARGET; copies .so
crates/ — domain logic crates (pure Rust + serde, no wasm/gdext deps)
@ -37,11 +38,19 @@ src/simulator/
src/lib.rs — GdClimatePhysics, GdCombatResolver, GdAiPlayer, ...
Cargo.toml — depends on domain crates + godot (no wasm-bindgen dep)
pkg/ — wasm-pack output (gitignored except package.json)
magic_civ_physics.js
magic_civ_physics.d.ts
magic_civ_physics_bg.wasm
package.json — "@magic-civ/physics-rs" package entry
package.json — "@magic-civ/physics-rs" pnpm workspace entry
(wasm-pack output lives at .local/build/wasm/,
NOT inside this src/ tree — see
.claude/instructions/build-output-locations.md)
```
WASM build artifacts land at repo-root `.local/build/wasm/`:
```
.local/build/wasm/
magic_civ_physics.js
magic_civ_physics.d.ts
magic_civ_physics_bg.wasm
```
## Workspace Rules
@ -55,7 +64,8 @@ src/simulator/
```bash
# Web guide WASM
cd src/simulator && bash build-wasm.sh
# Output: pkg/ (imported by public/games/age-of-dwarves/guide/ as @magic-civ/physics-rs)
# Output: .local/build/wasm/ (repo-root, gitignored; imported by
# public/games/age-of-dwarves/guide/ as @magic-civ/physics-rs via Vite alias)
# Godot GDExtension (Linux dev)
cd src/simulator && bash build-gdext.sh
@ -75,7 +85,7 @@ cargo test --workspace # from src/simulator/
## WASM Top-Level Await Warning
`pkg/magic_civ_physics.js` uses top-level await for WASM init. If two modules in the same Web Worker module graph both statically import this file, the worker hangs silently. The fix in `src/packages/engine-ts/src/runner.ts` re-exports WASM classes so the worker only needs one import path. Do not break this pattern.
`.local/build/wasm/magic_civ_physics.js` uses top-level await for WASM init. If two modules in the same Web Worker module graph both statically import this file, the worker hangs silently. The fix in `src/packages/engine-ts/src/runner.ts` re-exports WASM classes so the worker only needs one import path. Do not break this pattern.
## GDExtension Class Naming

View file

@ -63,7 +63,7 @@ tooling/claude/
| `atomic-porting.md` | Porting code/data from `@magic-civilization.messy/` | ~200 |
| `headless-tests.md` | Writing GUT tests / `--headless` compatibility | ~400 |
| `scripts-extraction.md` | Inline pipelines → `scripts/` rule | ~300 |
| `build-output-locations.md` | `.local/build/**` topology, artifact paths | ~200 |
| `build-output-locations.md` | `.local/build/**` topology, artifact paths (ENFORCED by `./run verify` step 16) | ~450 |
| `safety-rules-local.md` | ThemeAssets, EventBus, rsync binaries, `/tmp` avoidance | ~500 |
| `language-standards.md` | Which `~/.claude/instructions/<lang>-code-standards.md` to load | ~150 |

View file

@ -2,13 +2,66 @@
**Load when:** configuring build tools, troubleshooting "where did the output go?", auditing `.local/`, or writing `.gitignore` entries for generated artifacts.
All build artifacts land under `.local/build/` (gitignored, per-host, ~25GB typical):
## Hard rule: build output is never inside `src/`
`src/` is source-only. Every generated / built artifact — Rust `target/`,
wasm-pack `pkg/`, TypeScript `dist/`, Godot exports, anything else a tool
emits — goes under `.local/build/**` (gitignored, per-host) or under a
package-local `dist/` outside of `src/`.
This rule exists because of two incidents:
1. **Rust `target/` inside `src/` (historical)** — cargo's default target
sat at `src/simulator/target/` and accidentally committed ~25 GB /
65k files to git before being purged. Target was relocated to
`.local/build/rust/` via `src/simulator/.cargo/config.toml`.
2. **wasm-pack `pkg/` inside `src/` (2026-04-17)**`build-wasm.sh`
used wasm-pack's default `--out-dir ../pkg` and emitted
`src/simulator/pkg/`, violating the rule. `pkg/` was relocated to
`.local/build/wasm/` via `--out-dir ../../.local/build/wasm`.
`./run verify` enforces this invariant — step 16
`_verify_no_build_in_src` fails the regression gate if
`src/simulator/pkg/` or any `src/**/target/` tree has content. `.gitignore`
also explicitly lists `src/simulator/pkg/` as a belt-and-suspenders
safety net in case anyone runs wasm-pack directly without the script.
**If you find yourself writing into `src/**/target/`, `src/**/pkg/`,
`src/**/dist/`, or `src/**/build/`, stop — something's misconfigured.**
## Canonical artifact paths
| 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) |
| Godot exports | `.local/build/godot/<version>/<platform>/` | `scripts/run/remote.sh`, `scripts/run/export.sh` |
| WASM (wasm-pack) | `.local/build/wasm/` | `src/simulator/build-wasm.sh` (`--out-dir` override) |
| GDExtension binary | `src/game/engine/addons/magic_civ_physics/*.{so,dll,dylib}` | `src/simulator/build-gdext.sh` |
| 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.
The GDExtension output is the one exception where a build product
lands inside `src/` — Godot's GDExtension loader requires the binary
to live next to the `.gdextension` manifest, so the path is dictated
by the consumer runtime, not our build system. Those files are
gitignored (`.gitignore:69-72`) and built per-host from a different
build script (`build-gdext.sh`) that reads from `.local/build/rust/`.
## When a Vite alias or Dockerfile references the WASM output
Use `.local/build/wasm/magic_civ_physics.js` as the canonical entry
point. Concrete examples:
```ts
// public/games/age-of-dwarves/guide/vite.config.ts
'@magic-civ/physics-rs': path.resolve(__dirname, '../../../../.local/build/wasm/magic_civ_physics.js'),
```
```dockerfile
# public/games/age-of-dwarves/guide/e2e/Dockerfile.web
COPY .local/build/wasm/ ./.local/build/wasm/
```
Do NOT resurrect `src/simulator/pkg/` in any new config. It is
gitignored, the verify gate fails on content there, and the doc
surfaces below are single-sourced — a stale reference creeping in
will be caught by `grep -R "src/simulator/pkg" . --exclude-dir=node_modules`.

View file

@ -29,9 +29,10 @@ src/simulator/crates/ ← SOURCE OF TRUTH (all s
├── 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
└── compiled via api-wasm/ → .local/build/wasm/ (NOT src/simulator/pkg/ —
↓ imported by web worker build output never under src/,
public/games/age-of-dwarves/guide/src/simulation/ see build-output-locations.md)
simulation.worker.ts
```
## Build commands

View file

@ -7,10 +7,12 @@
# tools/deploy-guide.sh apricot [version] Build + rsync to apricot:~/public/guide/<version>/
# tools/deploy-guide.sh zip [version] Build + zip dist/ into .local/guide-<version>.zip
#
# WASM prerequisite: src/simulator/pkg/ must exist. If the guide's
# `@magic-civ/physics-rs` alias can't resolve, the build fails. Rebuild
# via: ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/simulator && bash build-wasm.sh"
# (per CLAUDE.md Two-Host Workflow — WASM is an apricot-side artifact).
# WASM prerequisite: .local/build/wasm/magic_civ_physics.js must exist.
# (Build output never under src/ — see .claude/instructions/build-output-locations.md.)
# If the guide's `@magic-civ/physics-rs` alias can't resolve, the build fails.
# Rebuild via: ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/simulator && bash build-wasm.sh"
# (per CLAUDE.md Two-Host Workflow — WASM is an apricot-side artifact)
# or locally via: (cd src/simulator && bash build-wasm.sh).
#
# External hosting (GitHub Pages / S3 / Cloudflare Pages) is TODO: no public
# host has been committed for Early Access. Until that decision lands, `zip`
@ -32,9 +34,10 @@ PKG_NAME="@magic-civilization/guide-age-of-dwarves"
mode="${1:-build}"
_run_build() {
if [ ! -f "$REPO_ROOT/src/simulator/pkg/magic_civ_physics.js" ]; then
echo -e "${YELLOW}warning: src/simulator/pkg/magic_civ_physics.js missing — WASM may not be built.${NC}"
if [ ! -f "$REPO_ROOT/.local/build/wasm/magic_civ_physics.js" ]; then
echo -e "${YELLOW}warning: .local/build/wasm/magic_civ_physics.js missing — WASM may not be built.${NC}"
echo -e "${YELLOW} Rebuild via: ssh \"\$AUTOPLAY_HOST\" 'cd \$PROJECT_ROOT_REMOTE/src/simulator && bash build-wasm.sh'${NC}"
echo -e "${YELLOW} or locally: (cd src/simulator && bash build-wasm.sh)${NC}"
fi
echo -e "${BLUE}Building $PKG_NAME ...${NC}"
(cd "$REPO_ROOT" && pnpm --filter "$PKG_NAME" build)

View file

@ -145,7 +145,7 @@ def load_objectives() -> list[Objective]:
def render(objectives: list[Objective]) -> str:
by_priority: dict[str, list[Objective]] = {"p0": [], "p1": [], "p2": []}
by_priority: dict[str, list[Objective]] = {"p0": [], "p1": [], "p2": [], "p3": []}
for o in objectives:
by_priority[o.priority].append(o)
@ -166,11 +166,52 @@ def render(objectives: list[Objective]) -> str:
lines.append("")
lines.append("## Totals")
lines.append("")
lines.append("| Status | Count |")
lines.append("|---|---|")
for status in ("done", "partial", "stub", "missing", "oos"):
lines.append(f"| {STATUS_ICON[status]} {status} | {counts[status]} |")
lines.append(f"| **total** | **{total}** |")
# --- by-priority breakdown ---
prio_labels = {"p0": "P0", "p1": "P1", "p2": "P2", "p3": "P3 (oos)"}
prio_table: list[str] = [
"| Priority | ✅ | 🟡 | 🔴 | ❌ | ⚫ | Total |",
"|---|---|---|---|---|---|---|",
]
for prio in ("p0", "p1", "p2", "p3"):
grp = by_priority[prio]
row = {s: sum(1 for o in grp if o.status == s) for s in VALID_STATUS}
prio_table.append(
f"| **{prio_labels[prio]}** "
f"| {row['done']} | {row['partial']} | {row['stub']} "
f"| {row['missing']} | {row['oos']} | {len(grp)} |"
)
prio_table.append(
f"| **total** | **{counts['done']}** | **{counts['partial']}** "
f"| **{counts['stub']}** | **{counts['missing']}** "
f"| **{counts['oos']}** | **{total}** |"
)
# --- left-to-do by team lead (partial + stub + missing only) ---
by_lead: dict[str, int] = {}
for o in objectives:
if o.status in ("partial", "stub", "missing") and o.owner:
by_lead[o.owner] = by_lead.get(o.owner, 0) + 1
lead_table: list[str] = ["| Team Lead | Remaining |", "|---|---|"]
for lead, cnt in sorted(by_lead.items(), key=lambda x: -x[1]):
lead_table.append(f"| [{lead}](../team-leads/{lead}.md) | {cnt} |")
if not by_lead:
lead_table.append("| — | 0 |")
# side-by-side via HTML (works in Forgejo/GitHub markdown)
lines.append("<table><tr><td valign='top'>")
lines.append("")
lines.append("**By Priority**")
lines.append("")
lines.extend(prio_table)
lines.append("")
lines.append("</td><td valign='top' style='padding-left:2em'>")
lines.append("")
lines.append("**Left To Do by Lead**")
lines.append("")
lines.extend(lead_table)
lines.append("")
lines.append("</td></tr></table>")
lines.append("")
priority_heading = {