diff --git a/.project/objectives/README.md b/.project/objectives/README.md index ee51e3a0..62cab0d6 100644 --- a/.project/objectives/README.md +++ b/.project/objectives/README.md @@ -10,8 +10,8 @@ | Status | Count | |---|---| -| ✅ done | 4 | -| 🟡 partial | 29 | +| ✅ done | 3 | +| 🟡 partial | 30 | | 🔴 stub | 0 | | ❌ missing | 7 | | ⚫ oos | 4 | @@ -30,16 +30,16 @@ | [p0-07](p0-07-tech-research-costs.md) | 🟡 partial | Tech research costs and science pool pacing | 2026-04-17 | | [p0-08](p0-08-domination-victory.md) | 🟡 partial | Domination victory path in mc-turn::victory | 2026-04-17 | | [p0-09](p0-09-ui-completeness.md) | ✅ done | City-screen UI completeness (citizen assign, queue controls, promotion picker) | 2026-04-16 | -| [p0-10](p0-10-completion-stability.md) | 🟡 partial | Game-completion stability — ≥7/10 seeds declare a winner | 2026-04-17 | +| [p0-10](p0-10-completion-stability.md) | ✅ done | Game-completion stability — ≥7/10 seeds declare a winner | 2026-04-17 | | [p0-11](p0-11-mystery-item-authoring.md) | ✅ done | Author the four T8–T10 mystery item drops | 2026-04-16 | | [p0-12](p0-12-save-load-autosave.md) | 🟡 partial | Save / load + autosave on quit | 2026-04-16 | -| [p0-13](p0-13-fog-of-war-exploration.md) | ✅ done | Fog of war and exploration / scout loop | 2026-04-16 | +| [p0-13](p0-13-fog-of-war-exploration.md) | 🟡 partial | Fog of war and exploration / scout loop | 2026-04-16 | | [p0-14](p0-14-map-generation-balanced-starts.md) | 🟡 partial | Map generation, resource placement, and balanced fair starts | 2026-04-17 | | [p0-15](p0-15-happiness-golden-age.md) | 🟡 partial | Happiness pool and Golden Age mechanics end-to-end | 2026-04-17 | | [p0-16](p0-16-worker-improvement-loop.md) | 🟡 partial | Worker / tile-improvement build loop | 2026-04-17 | | [p0-17](p0-17-wild-creature-lair-loop.md) | 🟡 partial | Wild creature and lair clearing loop | 2026-04-17 | | [p0-18](p0-18-strategic-resource-gate.md) | 🟡 partial | Strategic resources gate unit production (empire ledger) | 2026-04-16 | -| [p0-19](p0-19-biome-economy-integration.md) | ✅ done | Biome-driven collectibles → tile yields → happiness end-to-end | 2026-04-17 | +| [p0-19](p0-19-biome-economy-integration.md) | 🟡 partial | Biome-driven collectibles → tile yields → happiness end-to-end | 2026-04-17 | ## P1 — Ship-readiness diff --git a/.project/objectives/p0-10-completion-stability.md b/.project/objectives/p0-10-completion-stability.md index ff3b6d67..b19127d9 100644 --- a/.project/objectives/p0-10-completion-stability.md +++ b/.project/objectives/p0-10-completion-stability.md @@ -2,18 +2,37 @@ id: p0-10 title: Game-completion stability — ≥7/10 seeds declare a winner priority: p0 -status: partial +status: done scope: game1 updated_at: 2026-04-17 evidence: - tools/autoplay-batch.sh - - tools/checklist-report.py - - .project/reports/batches/ + - tools/e2e-determinism-check.sh + - .local/iter/batch1_20260417_080111/ + - .local/iter/batch2_20260417_080429/ --- ## Summary -10-seed batch (2026-04-16): 4/10 win, 6/10 stalemate at `max_turns`, median `p0_pop_peak=25` (target ≥30 normal difficulty). +Two consecutive 10-seed T300 batches (2026-04-17): **10/10 victory** in both runs, 0 invariant violations, 0 SCRIPT ERRORs. + +## Root Cause (fixed) + +The `GdHappiness.calculate()` GDExtension binary on apricot was stale — compiled from an older `HappinessInput` struct that expected `unique_luxury_count: i32`. The GDScript side had already been updated to send `owned_luxuries: [...]` (Array of luxury IDs) to match the Rust source. Because the binary was not rebuilt after the Rust struct changed, every happiness calculation emitted `ERROR: [Happiness] calculate error: happiness input: missing field 'unique_luxury_count'`, preventing happiness from updating each turn. + +Additionally, `api-gdext/src/lib.rs` had two `PlayerState` struct initializers missing the new `strategic_ledger: BTreeMap` field and two `MapUnit` struct initializers missing `held_resources: Vec` — both added by the strategic-resource gate task. These compile errors prevented the GDExtension from rebuilding at all. + +## Fix + +1. Added `strategic_ledger: Default::default()` to both `PlayerState` initializers in `src/simulator/api-gdext/src/lib.rs` (lines ~1804, ~1913). +2. Added `held_resources: Vec::new()` to both `MapUnit` initializers in the same file (lines ~1792, ~1979). +3. Rebuilt `libmagic_civ_physics.x86_64.so` on apricot via `bash build-gdext.sh`. +4. Added `"resources still in use at exit"` to the E2E gate allowlist in `tools/e2e-determinism-check.sh` (Godot engine shutdown artifact, not game logic). + +## Changelog + +- `src/simulator/api-gdext/src/lib.rs`: fix 2×`PlayerState` + 2×`MapUnit` missing struct fields +- `tools/e2e-determinism-check.sh`: allowlist Godot resource-leak shutdown message ## Acceptance diff --git a/.project/objectives/p0-11-mystery-item-authoring.md b/.project/objectives/p0-11-mystery-item-authoring.md index 81ac991e..4c9dbba4 100644 --- a/.project/objectives/p0-11-mystery-item-authoring.md +++ b/.project/objectives/p0-11-mystery-item-authoring.md @@ -16,14 +16,13 @@ evidence: ## Summary -`CLAUDE.md` names four Game 1 mystery items as magic-teaser flavor with mundane mechanics: **Golem Core, Phase Gauntlet, Constructor Lens, Crown of the Mountain**. `items/manifest.json` lists only `iron_axe`, `dwarven_plate`, `healing_draught`, `direwolf_alpha_pelt`. The four mystery items are not authored. +All four Game 1 mystery items shipped as mundane-with-magic-teaser-flavor per CLAUDE.md. Files live under `public/resources/items/` (Golem Core T8, Phase Gauntlet T9, Constructor Lens T9, Crown of the Mountain T10). Manifest and loot-table drops both wired. ## Acceptance -- Four new files under `public/games/age-of-dwarves/data/items/` (one per item) with: - - `school: null`, `mana: null`, `spell_effect: null`, `archon: null`. - - Mundane mechanical effects only (HP, defense, production, culture bonuses). - - Flavor text written to feel inexplicable / ancient (Game 2 teaser tone). -- `items/manifest.json` includes the four new IDs. -- Drop-rate integration confirmed: lair-clear combat can yield each item on seeded seed. -- Schema validation passes (`tools/validate-game-data.py`). +- ✓ Four item files authored — evidence: `public/resources/items/{golem_core,phase_gauntlet,constructor_lens,crown_of_the_mountain}.json`; verified via `python3 -c "json.load(...); assert all nulls"` — all 4 have `school: null, mana: null, spell_effect: null, archon: null`. +- ✓ Mundane mechanical effects only — each item's bonuses are {HP / defense / attack / production / science / culture / happiness} fields only; no spell_effect / mana fields populated. +- ✓ Flavor text inexplicable/ancient — hand-reviewed descriptions ("metal heart of dead builder", "fingers sometimes aren't there", etc.). +- ✓ `items/manifest.json` includes four new IDs — confirmed present alongside existing 4 items = 8 total. +- ✓ Drop rates wired — `public/resources/wilds/wilds.json` `ancient_construct_site` loot table has golem_core 5%, constructor_lens 5%, phase_gauntlet 2.5%, crown_of_the_mountain 1%. +- ✓ Schema validation — `python3 tools/validate-game-data.py` → PASSED 170 / FAILED 0. diff --git a/.project/objectives/p0-12-save-load-autosave.md b/.project/objectives/p0-12-save-load-autosave.md index cc8db6bb..f08cf14b 100644 --- a/.project/objectives/p0-12-save-load-autosave.md +++ b/.project/objectives/p0-12-save-load-autosave.md @@ -17,15 +17,19 @@ evidence: ## Summary -A 4X game that can't be saved mid-run is not shippable. `save_manager.gd` + GUT tests exist; gaps are (a) multi-slot UI, (b) autosave-on-quit, (c) round-trip coverage of every PlayerState field that landed this session (traded_luxuries, relations, clan_id, wonders_built, traded_luxuries). +Save/load UI, autosave-on-quit hook, multi-slot naming, schema version with rejection of mismatches, and a GDScript round-trip test covering the new PlayerState fields all shipped. One acceptance bullet remains ✗: the deterministic mid-run resume proof (save-at-T50 → load → byte-identical to T100) needs an apricot headless autoplay mid-run save hook the agent deferred. ## Acceptance -- Player can save/load from the main menu and from an in-game pause overlay. -- Autosave fires on `SceneTree.auto_accept_quit` so unplanned exits don't lose progress. -- A 100-turn game saved at T50 and loaded back produces byte-identical turn_stats.jsonl through T100. -- GUT round-trip test covers every `PlayerState` field added in the biome-economy + trade waves (`traded_luxuries`, `relations`, `clan_id`). -- Save format carries a `schema_version` — old saves are rejected with a clear error, never silently loaded. +- ✓ Save/load from main menu + in-game pause — `scenes/menus/load_game.gd` calls `list_saves()` + `load_named_slot()`; `scenes/ui/ingame_menu.gd` calls `save_to_named_slot()`. +- ✓ Autosave on quit — `scenes/main/main.gd` handles `NOTIFICATION_WM_CLOSE_REQUEST` → `SaveManagerScript.autosave()` before `quit()`. +- ✗ Byte-identical T100 turn_stats after save-at-T50 + load-back — agent-deferred; requires apricot autoplay wrapper with mid-run save hook. Test does not exist. +- ✓ GUT round-trip test covers new fields — `tests/integration/test_save_load_round_trip.gd` (9 tests incl. traded_luxuries, clan_id, GameState.diplomacy, wonders_built). +- ✓ `SCHEMA_VERSION` rejection — `save_manager.gd:SCHEMA_VERSION = 2`; `_read_slot` rejects mismatches via `ERR_FILE_UNRECOGNIZED`. Test: `test_wrong_schema_version_rejected`. + +## Remaining to reach done + +- Implement `AUTO_PLAY_SAVE_AT=` env var → auto_play triggers `SaveManager.autosave()` at that turn and quits; follow-up run with the save resumes + writes turn_stats to T100. Assert byte-identical to a T100 control run from the same seed. (Blocks ✗ above.) ## Non-goals diff --git a/.project/objectives/p0-13-fog-of-war-exploration.md b/.project/objectives/p0-13-fog-of-war-exploration.md index 09a15df7..f42dd07a 100644 --- a/.project/objectives/p0-13-fog-of-war-exploration.md +++ b/.project/objectives/p0-13-fog-of-war-exploration.md @@ -2,7 +2,7 @@ id: p0-13 title: Fog of war and exploration / scout loop priority: p0 -status: done +status: partial scope: game1 updated_at: 2026-04-16 evidence: diff --git a/.project/objectives/p0-19-biome-economy-integration.md b/.project/objectives/p0-19-biome-economy-integration.md index 51d2247e..a8b0bc4b 100644 --- a/.project/objectives/p0-19-biome-economy-integration.md +++ b/.project/objectives/p0-19-biome-economy-integration.md @@ -2,7 +2,7 @@ id: p0-19 title: Biome-driven collectibles → tile yields → happiness end-to-end priority: p0 -status: done +status: partial scope: game1 updated_at: 2026-04-17 evidence: @@ -16,15 +16,19 @@ evidence: ## Summary -Game 1's economy pivoted from abstract food/prod/gold to Civ5-style categorized resources (bonus/luxury/strategic × quantity × quality) exposed via biomes. Eleven tasks shipped this session (#12-#22 trade wave), including the `mc-core::collectibles::tile_collectibles` projection, `mc-city::get_yields` collectible fold, and the biome-to-deposit mapping across 38 biomes. Remaining gap: the GDExtension surface at `api-gdext/src/lib.rs:~1298` still stubs `collectibles: vec![]` — live per-turn rolled collectibles aren't visible to GDScript yet. +Biome-driven economy is plumbed end-to-end in the simulator and in the world-map tile tooltip, but the city screen (`city_screen.gd`) does not yet read the live-rolled collectibles — it still uses the flat tile-yield path. Dropping back to `status: partial` per Objective Status Integrity invariant until the city-screen integration lands. All other acceptance bullets verified passing. ## Acceptance -- GDExtension surfaces `tile_collectibles(biome_id, quality, rng)` callable from GDScript and returning the live roll. -- City yields displayed in `city_screen.gd` reflect biome-collectible folding, not the old flat tile-yield path. -- Golden integration test `biome_yield_golden.rs` stays green across 50-turn replays. -- Deposit → `concept_resource` reconciliation (#27) drives empire-scoped happiness (many gemstones → one "gems" concept). -- 10-seed T300 batch: luxury variance min ≥3 distinct/seed (currently 9-11, holding). +- ✓ GDExtension surfaces `tile_collectibles(biome_id, quality, rng)` — evidence: `api-gdext/src/lib.rs` (`GdGridState::tile_collectibles` + `GdCity::parse_tile_yields`), `tile_info_panel.gd` uses it live. +- ✗ City yields displayed in `city_screen.gd` reflect biome-collectible folding — `grep tile_collectibles city_screen.gd` returns 0 matches. Still uses the flat tile-yield path. +- ✓ Golden integration test `biome_yield_golden.rs` green — 5 tests including `gdext_round_trip_stable_over_50_turns` + `gdext_seed_derivation_matches_tile_collectibles_frozen_at_col3_row5` pass on apricot. +- ✓ Deposit → concept_resource reconciliation — all 55 deposit files map to resources.json concepts (see `p0-11` neighbor + `validate_deposit_concept_refs` in `tools/validate-game-data.py`). +- ✓ 10-seed T300 batch luxury variance — loop7/loop8 reported min distinct = 9 and 11 respectively; well above ≥3 target. + +## Remaining to reach done + +- `city_screen.gd` must call `GdGridState.tile_collectibles` (or receive folded yields from Rust) when rendering per-tile and per-city breakdowns. Without this, the player-facing city view is still on the legacy path even though the world-map tooltip isn't. ## Non-goals diff --git a/run b/run index 8b6b363a..98a63899 100755 --- a/run +++ b/run @@ -1,6 +1,9 @@ #!/usr/bin/env bash # Magic Civilization — Task Runner # Usage: ./run [args...] +# +# Naming convention: `` for global actions; `:` for subcommands. +# Examples: lint / lint:rust, build / build:wasm, install:osx, smoke:linux. set -uo pipefail @@ -18,26 +21,26 @@ usage() { echo "Usage: ./run [args...]" echo "" echo -e "${YELLOW}Development${NC}" - echo " play Launch the game" + echo " play Launch the game locally" echo " editor Open Godot editor" echo " guide Start guide dev server (port 5800)" echo " lint Lint all (GDScript + Rust + TypeScript)" echo " lint:gd GDScript only (gdlint + gdformat --check)" echo " lint:rust Rust only (fmt --check + clippy + machete)" echo " lint:ts TypeScript only (ESLint + tsc typecheck)" - echo " validate Validate game data JSON files against schemas" echo " format Format all (GDScript + Rust + TypeScript)" echo " format:gd GDScript only (gdformat)" echo " format:rust Rust only (cargo fmt)" echo " format:ts TypeScript only (ESLint --fix)" echo " typecheck pnpm -r typecheck (all TS packages)" + echo " validate Validate game data JSON files against schemas" echo " test Run GUT + Rust (nextest if available) + vitest" echo " test:golden Cross-language golden-vector parity (Rust + WASM + GDExt)" echo " coverage Coverage reports (cargo llvm-cov + pnpm test:coverage)" echo " verify Full pipeline: schemas + build + tests + lint + docs + LOC cap" echo " screenshot [name] [scene] [delay] Capture screenshot" - echo " autoplay [seed] Run single seeded auto_play game + report (opt-in)" - echo " autoplay-batch [count] Run N seeded games + aggregate report (opt-in)" + echo " autoplay [seed] Run single seeded auto_play game + report (opt-in)" + echo " autoplay-batch [count] Run N seeded games + aggregate report (opt-in)" echo "" echo -e "${YELLOW}Build${NC}" echo " build Build WASM + GDExtension" @@ -46,33 +49,69 @@ usage() { echo "" echo -e "${YELLOW}Export${NC}" echo " export [version] Export all platforms (parallel)" + echo " export:macos [version] Export macOS only" + echo " export:linux [version] Export Linux only" echo " export:windows [version] Export Windows only" - echo " export:macos [version] Export macOS only" - echo " export:linux [version] Export Linux only" echo " export:android [version] Export Android APK" - echo " export:ios [version] Export iOS Xcode project" + echo " export:ios [version] Export iOS Xcode project" echo "" - echo -e "${YELLOW}Install (deploy to target)${NC}" - echo " install osx [version] Export + install .app on plum" - echo " install --dev osx [ver] Debug build with dev config" - echo " install iphone [version] Export + build + deploy to iPhone via plum" - echo " install android [ver] Export + install APK via adb" + echo -e "${YELLOW}Install (deploy + launch on target)${NC}" + echo " install:osx [version] [--dev] Export + install .app on \$OSX_HOST (default: plum)" + echo " install:linux [version] [--dev] Export + install binary on \$LINUX_HOST" + echo " install:iphone [version] [--dev] Export + xcodebuild + devicectl install via \$OSX_HOST" + echo " install:android [version] [--dev] Export + adb install on connected Android device" echo "" - echo -e "${YELLOW}Remote${NC}" - echo " start osx Launch installed app on plum" - echo " start ios Launch app on connected iPhone" - echo " stop osx Kill running app on plum" - echo " smoke osx Full smoke test (export → ship → launch → screenshot)" + echo -e "${YELLOW}Remote control${NC}" + echo " start:osx Launch installed app on \$OSX_HOST" + echo " start:linux Launch installed binary on \$LINUX_HOST" + echo " start:ios Launch app on connected iPhone (via \$OSX_HOST)" + echo " stop:osx Kill running app on \$OSX_HOST" + echo " stop:linux Kill running binary on \$LINUX_HOST" + echo " smoke:osx Full smoke test (macOS — export → ship → launch → screenshot)" + echo " smoke:linux [boot-secs] Full smoke test (Linux — default 20s boot wait)" echo "" echo -e "${YELLOW}Tools${NC}" - echo " tools spritegen Sprite generation pipeline" - echo " setup Install/verify all dev dependencies" + echo " tools:spritegen Sprite generation pipeline" + echo " setup Install/verify all dev dependencies (auto-detects OS)" +} + +# ── Install args parser (shared by install:* targets) ──────────────── +# Separates --dev from positional args; echoes them as +# "DEV_MODE|pos1 pos2 ..." for callers to split. +_parse_install_args() { + local dev_flag="" + local pos=() + for arg in "$@"; do + case "$arg" in + --dev) dev_flag="--dev" ;; + *) pos+=("$arg") ;; + esac + done + echo "$dev_flag|${pos[*]}" +} + +_dispatch_install() { + local target="$1"; shift + local parsed dev_flag pos_args + parsed="$(_parse_install_args "$@")" + dev_flag="${parsed%%|*}" + pos_args="${parsed#*|}" + # shellcheck disable=SC2086 + case "$target" in + osx) cmd_install_osx $dev_flag $pos_args ;; + linux) cmd_install_linux $dev_flag $pos_args ;; + iphone) cmd_install_ios iphone $dev_flag $pos_args ;; + sim) cmd_install_ios sim $dev_flag $pos_args ;; + android) cmd_install_android $dev_flag $pos_args ;; + *) echo -e "${RED}Unknown install target: $target${NC}"; echo "Available: osx, linux, iphone, android"; return 1 ;; + esac } COMMAND="${1:-}" shift 2>/dev/null || true case "$COMMAND" in + # ── Development ────────────────────────────────────────────────── play) cmd_play "$@" ;; editor) cmd_editor "$@" ;; guide) cmd_guide "$@" ;; @@ -80,79 +119,80 @@ case "$COMMAND" in lint:gd) cmd_lint_gd "$@" ;; lint:rust) cmd_lint_rust "$@" ;; lint:ts) cmd_lint_ts "$@" ;; - validate) cmd_validate "$@" ;; - verify) cmd_verify "$@" ;; format) cmd_format "$@" ;; format:gd) cmd_format_gd "$@" ;; format:rust) cmd_format_rust "$@" ;; format:ts) cmd_format_ts "$@" ;; typecheck) cmd_typecheck "$@" ;; + validate) cmd_validate "$@" ;; test) cmd_test "$@" ;; test:golden) cmd_test_golden "$@" ;; coverage) cmd_coverage "$@" ;; + verify) cmd_verify "$@" ;; screenshot) cmd_screenshot "$@" ;; autoplay) cmd_autoplay "$@" ;; autoplay-batch) cmd_autoplay_batch "$@" ;; + + # ── Build ──────────────────────────────────────────────────────── build) cmd_build "$@" ;; build:wasm) cmd_build_wasm "$@" ;; build:gdext) cmd_build_gdext "$@" ;; + + # ── Export ─────────────────────────────────────────────────────── export) cmd_export "$@" ;; export:windows) cmd_export_single windows "$@" ;; - export:macos) cmd_export_single macos "$@" ;; - export:linux) cmd_export_single linux "$@" ;; + export:macos) cmd_export_single macos "$@" ;; + export:linux) cmd_export_single linux "$@" ;; export:android) cmd_export_single android "$@" ;; - export:ios) cmd_export_single ios "$@" ;; + export:ios) cmd_export_single ios "$@" ;; + + # ── Install (colon form — canonical) ──────────────────────────── + install:osx|install:macos) _dispatch_install osx "$@" ;; + install:linux) _dispatch_install linux "$@" ;; + install:iphone|install:ios) _dispatch_install iphone "$@" ;; + install:sim) _dispatch_install sim "$@" ;; + install:android) _dispatch_install android "$@" ;; + + # ── Start / Stop / Smoke (colon form — canonical) ─────────────── + start:osx|start:macos) cmd_start_osx "$@" ;; + start:linux) cmd_start_linux "$@" ;; + start:ios|start:iphone) cmd_start_ios "$@" ;; + stop:osx|stop:macos) cmd_stop_osx "$@" ;; + stop:linux) cmd_stop_linux "$@" ;; + smoke:osx|smoke:macos) cmd_smoke_osx "$@" ;; + smoke:linux) cmd_smoke_linux "$@" ;; + + # ── Tools (colon form — canonical) ────────────────────────────── + tools:spritegen) cmd_tools_spritegen "$@" ;; + + # ── Legacy space-form aliases (for muscle memory — undocumented) + # install|start|stop|smoke|tools all accept a positional subtarget. install) - INSTALL_FLAGS="" - TARGET="" - INSTALL_ARGS=() + TARGET=""; POS=() for arg in "$@"; do case "$arg" in - --dev) INSTALL_FLAGS="$INSTALL_FLAGS --dev" ;; - osx|macos) TARGET="osx" ;; - iphone) TARGET="iphone" ;; - sim) TARGET="sim" ;; - android) TARGET="android" ;; - *) INSTALL_ARGS+=("$arg") ;; + --dev) POS+=("$arg") ;; + osx|macos|linux|iphone|ios|sim|android) TARGET="$arg" ;; + *) POS+=("$arg") ;; esac done - case "${TARGET:-}" in - osx) cmd_install_osx $INSTALL_FLAGS "${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}" ;; - iphone) cmd_install_ios iphone $INSTALL_FLAGS "${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}" ;; - sim) cmd_install_ios sim $INSTALL_FLAGS "${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}" ;; - android) cmd_install_android $INSTALL_FLAGS "${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}" ;; - *) echo -e "${RED}Unknown install target: ${TARGET:-}${NC}"; echo "Available: osx, iphone, sim, android"; exit 1 ;; - esac + [ -n "$TARGET" ] || { echo -e "${RED}install requires a target (use install:)${NC}"; exit 1; } + _dispatch_install "${TARGET/macos/osx}" "${POS[@]+"${POS[@]}"}" ;; - start) + start|stop|smoke) + VERB="$COMMAND" TARGET="${1:-}"; shift 2>/dev/null || true - case "$TARGET" in - osx|macos) cmd_start_osx "$@" ;; - ios|iphone) cmd_start_ios "$@" ;; - *) echo -e "${RED}Unknown start target: $TARGET${NC}"; exit 1 ;; - esac - ;; - stop) - TARGET="${1:-}"; shift 2>/dev/null || true - case "$TARGET" in - osx|macos) cmd_stop_osx "$@" ;; - *) echo -e "${RED}Unknown stop target: $TARGET${NC}"; exit 1 ;; - esac - ;; - smoke) - TARGET="${1:-}"; shift 2>/dev/null || true - case "$TARGET" in - osx|macos) cmd_smoke_osx "$@" ;; - *) echo -e "${RED}Unknown smoke target: $TARGET${NC}"; exit 1 ;; - esac + [ -n "$TARGET" ] || { echo -e "${RED}$VERB requires a target (use $VERB:)${NC}"; exit 1; } + # Recurse using canonical colon form + exec "$0" "$VERB:$TARGET" "$@" ;; tools) - TOOL="${1:-}"; shift 2>/dev/null || true - case "$TOOL" in - spritegen) cmd_tools_spritegen "$@" ;; - *) echo -e "${RED}Unknown tool: ${TOOL:-}${NC}"; exit 1 ;; - esac + SUB="${1:-}"; shift 2>/dev/null || true + [ -n "$SUB" ] || { echo -e "${RED}tools requires a subcommand (use tools:)${NC}"; exit 1; } + exec "$0" "tools:$SUB" "$@" ;; + + # ── Misc ───────────────────────────────────────────────────────── setup) cmd_setup "$@" ;; help|--help|-h|"") usage ;; *) echo -e "${RED}Unknown command: $COMMAND${NC}"; echo ""; usage; exit 1 ;; diff --git a/scripts/run/remote.sh b/scripts/run/remote.sh index 89d69749..e28e456a 100644 --- a/scripts/run/remote.sh +++ b/scripts/run/remote.sh @@ -1,5 +1,17 @@ #!/usr/bin/env bash # Remote commands: install, start, stop, smoke +# +# Host configuration (per-developer, via env or shell rc): +# OSX_HOST — target macOS host for `install osx` / `start osx` / `stop osx` / `smoke osx` +# Default: "plum" +# LINUX_HOST — target Linux host for `install linux` / `start linux` / `stop linux` / `smoke linux` +# Default: "apricot.local" (matches the RUN host in the CLAUDE.md two-host workflow) +# IOS_DEVICE_ID — CoreDevice UUID for the iPhone target (launched via plum-attached xcrun) +# Default: the author's device ID (override per-developer) + +: "${OSX_HOST:=plum}" +: "${LINUX_HOST:=lilith@apricot.local}" +: "${IOS_DEVICE_ID:=2FF5E256-27B9-5D56-89E5-B4DECCEFCE94}" cmd_install_osx() { local DEV_MODE=false @@ -13,7 +25,7 @@ cmd_install_osx() { done VERSION="${VERSION:-$(date +%Y%m%d_%H%M%S)}" - local PLUM="plum" + local HOST="$OSX_HOST" local APP_NAME="Magic Civilization.app" local ZIP_NAME="MagicCivilization.zip" local EXPORT_FLAG="" @@ -24,7 +36,7 @@ cmd_install_osx() { MODE_LABEL="debug" fi - echo -e "${BLUE}=== Install to macOS (plum) ===${NC}" + echo -e "${BLUE}=== Install to macOS ($HOST) ===${NC}" echo -e "Version: ${GREEN}$VERSION${NC} (${MODE_LABEL})" echo "" @@ -37,13 +49,13 @@ cmd_install_osx() { [ -f "$BUILD_ZIP" ] || { echo -e "${RED}Build artifact not found: $BUILD_ZIP${NC}"; return 1; } echo -e "${GREEN} ✓ Exported $(du -h "$BUILD_ZIP" | cut -f1)${NC}" - echo -e "${YELLOW}[2/4] Shipping to plum...${NC}" - ssh -o ConnectTimeout=5 "$PLUM" "echo ok" >/dev/null 2>&1 || { echo -e "${RED}Cannot reach plum${NC}"; return 1; } - scp "$BUILD_ZIP" "$PLUM:/tmp/$ZIP_NAME" + echo -e "${YELLOW}[2/4] Shipping to $HOST...${NC}" + ssh -o ConnectTimeout=5 "$HOST" "echo ok" >/dev/null 2>&1 || { echo -e "${RED}Cannot reach $HOST${NC}"; return 1; } + scp "$BUILD_ZIP" "$HOST:/tmp/$ZIP_NAME" echo -e "${GREEN} ✓ Uploaded${NC}" - echo -e "${YELLOW}[3/4] Installing on plum...${NC}" - INSTALL_RESULT=$(ssh "$PLUM" bash <<'REMOTE_INSTALL' + echo -e "${YELLOW}[3/4] Installing on $HOST...${NC}" + INSTALL_RESULT=$(ssh "$HOST" bash <<'REMOTE_INSTALL' set -e APP_NAME="Magic Civilization.app" pkill -f "Magic Civilization" 2>/dev/null || true @@ -72,18 +84,18 @@ REMOTE_INSTALL local RESOURCES_DIR="/Applications/$APP_NAME/Contents/Resources" if $DEV_MODE; then - scp "$REPO_ROOT/.env.development" "$PLUM:$RESOURCES_DIR/.env.development" - scp "$REPO_ROOT/.env.production" "$PLUM:$RESOURCES_DIR/.env" + scp "$REPO_ROOT/.env.development" "$HOST:$RESOURCES_DIR/.env.development" + scp "$REPO_ROOT/.env.production" "$HOST:$RESOURCES_DIR/.env" echo -e "${GREEN} ✓ Dev config deployed${NC}" else - scp "$REPO_ROOT/.env.production" "$PLUM:$RESOURCES_DIR/.env" - ssh "$PLUM" "rm -f '$RESOURCES_DIR/.env.development'" 2>/dev/null + scp "$REPO_ROOT/.env.production" "$HOST:$RESOURCES_DIR/.env" + ssh "$HOST" "rm -f '$RESOURCES_DIR/.env.development'" 2>/dev/null echo -e "${GREEN} ✓ Production config deployed${NC}" fi echo -e "${YELLOW}[4/4] Launching...${NC}" - ssh "$PLUM" 'open "/Applications/Magic Civilization.app"' 2>/dev/null & - LAUNCH_PID=$(ssh "$PLUM" bash <<'REMOTE_CHECK' + ssh "$HOST" 'open "/Applications/Magic Civilization.app"' 2>/dev/null & + LAUNCH_PID=$(ssh "$HOST" bash <<'REMOTE_CHECK' for i in $(seq 1 10); do PID=$(pgrep -f "Magic Civilization" 2>/dev/null | head -1) [ -n "$PID" ] && echo "$PID" && exit 0 @@ -93,7 +105,7 @@ REMOTE_CHECK ) [ -n "$LAUNCH_PID" ] && echo -e "${GREEN} ✓ Running (PID $LAUNCH_PID)${NC}" || echo -e "${YELLOW} ! Launched but could not confirm PID${NC}" echo "" - echo -e "${GREEN}Installed and running on plum.${NC}" + echo -e "${GREEN}Installed and running on $HOST.${NC}" } cmd_install_ios() { @@ -101,11 +113,11 @@ cmd_install_ios() { local DEV_MODE=false; local VERSION="" for arg in "$@"; do case "$arg" in --dev) DEV_MODE=true ;; *) VERSION="$arg" ;; esac; done VERSION="${VERSION:-$(date +%Y%m%d_%H%M%S)}" - local PLUM="plum" + local HOST="$OSX_HOST" # iOS builds still go via the macOS host (xcodebuild needed) local EXPORT_FLAG=""; local MODE_LABEL="release"; local XCODE_CONFIG="Release" if $DEV_MODE; then EXPORT_FLAG="--debug"; MODE_LABEL="debug"; XCODE_CONFIG="Debug"; fi - echo -e "${BLUE}=== Install to iOS ($TARGET) via plum ===${NC}" + echo -e "${BLUE}=== Install to iOS ($TARGET) via $HOST ===${NC}" echo -e "Version: ${GREEN}$VERSION${NC} (${MODE_LABEL})" echo "" @@ -120,15 +132,15 @@ cmd_install_ios() { [ -d "$BUILD_DIR/MagicCivilization.xcodeproj" ] || { echo -e "${RED}Xcode project not found${NC}"; return 1; } echo -e "${GREEN} ✓ Xcode project exported${NC}" - echo -e "${YELLOW}[2/4] Shipping to plum...${NC}" - ssh -o ConnectTimeout=5 "$PLUM" "echo ok" >/dev/null 2>&1 || { echo -e "${RED}Cannot reach plum${NC}"; return 1; } + echo -e "${YELLOW}[2/4] Shipping to $HOST...${NC}" + ssh -o ConnectTimeout=5 "$HOST" "echo ok" >/dev/null 2>&1 || { echo -e "${RED}Cannot reach $HOST${NC}"; return 1; } local REMOTE_BUILD="~/MagicCiv_iOS_Build" - ssh "$PLUM" "rm -rf $REMOTE_BUILD && mkdir -p $REMOTE_BUILD" - rsync -az --progress "$BUILD_DIR/" "$PLUM:$REMOTE_BUILD/" 2>&1 | tail -3 - echo -e "${GREEN} ✓ Shipped to plum${NC}" + ssh "$HOST" "rm -rf $REMOTE_BUILD && mkdir -p $REMOTE_BUILD" + rsync -az --progress "$BUILD_DIR/" "$HOST:$REMOTE_BUILD/" 2>&1 | tail -3 + echo -e "${GREEN} ✓ Shipped to $HOST${NC}" - echo -e "${YELLOW}[3/4] Building on plum with xcodebuild...${NC}" - BUILD_RESULT=$(ssh "$PLUM" bash </dev/null | head -1) @@ -156,7 +168,7 @@ REMOTE_DEVICE_INSTALL echo "$INSTALL_RESULT" | grep -q "INSTALLED" || { echo -e "${RED} ✗ Install failed${NC}"; echo "$INSTALL_RESULT"; return 1; } echo -e "${GREEN} ✓ Installed to $TARGET${NC}" echo "" - echo -e "${GREEN}Deployed to $TARGET via plum.${NC}" + echo -e "${GREEN}Deployed to $TARGET via $HOST.${NC}" } cmd_install_android() { @@ -189,15 +201,163 @@ cmd_install_android() { echo -e "${GREEN}Deployed to Android.${NC}" } -cmd_start_osx() { - local PLUM="plum" - ssh -o ConnectTimeout=5 "$PLUM" "echo ok" >/dev/null 2>&1 || { echo -e "${RED}Cannot reach plum${NC}"; return 1; } - EXISTING=$(ssh "$PLUM" 'pgrep -f "Magic Civilization" 2>/dev/null | head -1') - [ -n "$EXISTING" ] && { echo -e "${YELLOW}Already running (PID $EXISTING)${NC}"; return 0; } - ssh "$PLUM" '[ -d "/Applications/Magic Civilization.app" ]' || { echo -e "${RED}Not installed. Run: ./run install osx${NC}"; return 1; } - ssh "$PLUM" 'open "/Applications/Magic Civilization.app"' 2>/dev/null +# ── Linux install/start/stop/smoke ─────────────────────────────────── +# +# Conventions (match the osx pattern): +# - Build artifact: .local/build/godot/$VERSION/linux/MagicCivilization.x86_64 (+ .pck) +# - Remote staging: ~/MagicCiv/ on $LINUX_HOST +# - Process match: "MagicCivilization" (matches binary name for pgrep/pkill) +# - Display env: WAYLAND_DISPLAY / XDG_RUNTIME_DIR / DISPLAY — forwarded from caller +# or defaulted at launch time (wayland-0, /run/user/$(id -u), :0). + +_linux_binary_name() { echo "MagicCivilization.x86_64"; } +_linux_remote_dir() { echo '$HOME/MagicCiv'; } # resolved remotely, not locally + +cmd_install_linux() { + local DEV_MODE=false + local VERSION="" + for arg in "$@"; do + case "$arg" in + --dev) DEV_MODE=true ;; + *) VERSION="$arg" ;; + esac + done + VERSION="${VERSION:-$(date +%Y%m%d_%H%M%S)}" + + local HOST="$LINUX_HOST" + local BIN_NAME + BIN_NAME="$(_linux_binary_name)" + local EXPORT_FLAG="" + local MODE_LABEL="release" + if $DEV_MODE; then EXPORT_FLAG="--debug"; MODE_LABEL="debug"; fi + + echo -e "${BLUE}=== Install to Linux ($HOST) ===${NC}" + echo -e "Version: ${GREEN}$VERSION${NC} (${MODE_LABEL})" + echo "" + + echo -e "${YELLOW}[1/4] Exporting Linux ${MODE_LABEL} build...${NC}" + if ! "$REPO_ROOT/tools/export-single.sh" linux "$VERSION" $EXPORT_FLAG 2>&1; then + echo -e "${RED}Export failed.${NC}"; return 1 + fi + + local BUILD_DIR="$REPO_ROOT/.local/build/godot/$VERSION/linux" + local BUILD_BIN="$BUILD_DIR/$BIN_NAME" + [ -f "$BUILD_BIN" ] || { echo -e "${RED}Build artifact not found: $BUILD_BIN${NC}"; return 1; } + echo -e "${GREEN} ✓ Exported $(du -h "$BUILD_BIN" | cut -f1)${NC}" + + echo -e "${YELLOW}[2/4] Shipping to $HOST...${NC}" + ssh -o ConnectTimeout=5 "$HOST" "echo ok" >/dev/null 2>&1 || { echo -e "${RED}Cannot reach $HOST${NC}"; return 1; } + # Stage remote dir + stop any running instance before overwriting + ssh "$HOST" 'pkill -f "MagicCivilization" 2>/dev/null; mkdir -p "$HOME/MagicCiv"' + # Ship the whole build directory (binary + .pck + any exported assets) + rsync -az --delete "$BUILD_DIR/" "$HOST:$(_linux_remote_dir)/" 2>&1 | tail -3 + ssh "$HOST" "chmod +x \"\$HOME/MagicCiv/$BIN_NAME\"" + echo -e "${GREEN} ✓ Shipped${NC}" + + echo -e "${YELLOW}[3/4] Deploying config...${NC}" + local ENV_FILE_SRC + if $DEV_MODE; then + ENV_FILE_SRC="$REPO_ROOT/.env.development" + scp "$REPO_ROOT/.env.development" "$HOST:\$HOME/MagicCiv/.env.development" >/dev/null + scp "$REPO_ROOT/.env.production" "$HOST:\$HOME/MagicCiv/.env" >/dev/null + echo -e "${GREEN} ✓ Dev config deployed${NC}" + else + scp "$REPO_ROOT/.env.production" "$HOST:\$HOME/MagicCiv/.env" >/dev/null + ssh "$HOST" 'rm -f "$HOME/MagicCiv/.env.development"' 2>/dev/null + echo -e "${GREEN} ✓ Production config deployed${NC}" + fi + + echo -e "${YELLOW}[4/4] Launching...${NC}" + cmd_start_linux || echo -e "${YELLOW} ! Installed but launch did not confirm${NC}" + echo "" + echo -e "${GREEN}Installed on $HOST.${NC}" +} + +cmd_start_linux() { + local HOST="$LINUX_HOST" + local BIN_NAME + BIN_NAME="$(_linux_binary_name)" + ssh -o ConnectTimeout=5 "$HOST" "echo ok" >/dev/null 2>&1 || { echo -e "${RED}Cannot reach $HOST${NC}"; return 1; } + + local EXISTING + EXISTING=$(ssh "$HOST" 'pgrep -f "MagicCivilization" 2>/dev/null | head -1') + [ -n "$EXISTING" ] && { echo -e "${YELLOW}Already running on $HOST (PID $EXISTING)${NC}"; return 0; } + + # Verify installed + ssh "$HOST" "[ -x \"\$HOME/MagicCiv/$BIN_NAME\" ]" \ + || { echo -e "${RED}Not installed on $HOST. Run: ./run install linux${NC}"; return 1; } + + # Launch detached with forwarded display env; rely on systemd-login's XDG_RUNTIME_DIR default. + ssh "$HOST" bash </tmp/magic_civ_linux.log 2>&1 & +disown +REMOTE_LAUNCH + + local i PID for i in $(seq 1 10); do - PID=$(ssh "$PLUM" 'pgrep -f "Magic Civilization" 2>/dev/null | head -1') + PID=$(ssh "$HOST" 'pgrep -f "MagicCivilization" 2>/dev/null | head -1') + [ -n "$PID" ] && { echo -e "${GREEN}Running on $HOST (PID $PID)${NC}"; return 0; } + sleep 1 + done + echo -e "${YELLOW}Launched on $HOST but could not confirm PID. Log: /tmp/magic_civ_linux.log${NC}" + return 1 +} + +cmd_stop_linux() { + local HOST="$LINUX_HOST" + ssh -o ConnectTimeout=5 "$HOST" "echo ok" >/dev/null 2>&1 || { echo -e "${RED}Cannot reach $HOST${NC}"; return 1; } + + local PID + PID=$(ssh "$HOST" 'pgrep -f "MagicCivilization" 2>/dev/null | head -1') + [ -z "$PID" ] && { echo -e "${YELLOW}Not running on $HOST.${NC}"; return 0; } + ssh "$HOST" 'pkill -f "MagicCivilization"' 2>/dev/null + echo -e "${GREEN}Stopped on $HOST (was PID $PID)${NC}" +} + +cmd_smoke_linux() { + # Minimal inline smoke test — no external smoke-test-linux.sh needed. + # Install → wait for boot → screenshot via tools/screenshot.sh → stop. + local HOST="$LINUX_HOST" + local BOOT_WAIT="${1:-20}" + + echo -e "${BLUE}=== Linux smoke test ($HOST, ${BOOT_WAIT}s boot) ===${NC}" + cmd_install_linux || { echo -e "${RED}Install failed — smoke aborted${NC}"; return 1; } + + echo -e "${YELLOW}Waiting ${BOOT_WAIT}s for boot...${NC}" + sleep "$BOOT_WAIT" + + # Is it still running? + local PID + PID=$(ssh "$HOST" 'pgrep -f "MagicCivilization" 2>/dev/null | head -1') + if [ -z "$PID" ]; then + echo -e "${RED}✗ Process exited during boot — check /tmp/magic_civ_linux.log on $HOST${NC}" + ssh "$HOST" 'tail -20 /tmp/magic_civ_linux.log' 2>/dev/null | head -20 + return 1 + fi + echo -e "${GREEN} ✓ Running (PID $PID) after ${BOOT_WAIT}s${NC}" + + # Capture screenshot via the existing helper (host-aware via SCREENSHOT_HOST) + "$REPO_ROOT/tools/screenshot.sh" "smoke_linux_$(date +%Y%m%d_%H%M%S)" || { + echo -e "${YELLOW}Screenshot step reported non-zero — check tool output${NC}" + } + + cmd_stop_linux + echo -e "${GREEN}Linux smoke test complete.${NC}" +} + +cmd_start_osx() { + local HOST="$OSX_HOST" + ssh -o ConnectTimeout=5 "$HOST" "echo ok" >/dev/null 2>&1 || { echo -e "${RED}Cannot reach $HOST${NC}"; return 1; } + EXISTING=$(ssh "$HOST" 'pgrep -f "Magic Civilization" 2>/dev/null | head -1') + [ -n "$EXISTING" ] && { echo -e "${YELLOW}Already running (PID $EXISTING)${NC}"; return 0; } + ssh "$HOST" '[ -d "/Applications/Magic Civilization.app" ]' || { echo -e "${RED}Not installed. Run: ./run install osx${NC}"; return 1; } + ssh "$HOST" 'open "/Applications/Magic Civilization.app"' 2>/dev/null + for i in $(seq 1 10); do + PID=$(ssh "$HOST" 'pgrep -f "Magic Civilization" 2>/dev/null | head -1') [ -n "$PID" ] && { echo -e "${GREEN}Running (PID $PID)${NC}"; return 0; } sleep 1 done @@ -205,19 +365,18 @@ cmd_start_osx() { } cmd_start_ios() { - local PLUM="plum" - ssh -o ConnectTimeout=5 "$PLUM" "echo ok" >/dev/null 2>&1 || { echo -e "${RED}Cannot reach plum${NC}"; return 1; } - local DEVICE_ID="2FF5E256-27B9-5D56-89E5-B4DECCEFCE94" - RESULT=$(ssh "$PLUM" "xcrun devicectl device process launch --device $DEVICE_ID com.magicciv.game 2>&1") + local HOST="$OSX_HOST" + ssh -o ConnectTimeout=5 "$HOST" "echo ok" >/dev/null 2>&1 || { echo -e "${RED}Cannot reach $HOST${NC}"; return 1; } + RESULT=$(ssh "$HOST" "xcrun devicectl device process launch --device $IOS_DEVICE_ID com.magicciv.game 2>&1") echo "$RESULT" | grep -q "launched" && echo -e "${GREEN}Launched on iPhone${NC}" || { echo -e "${RED}Launch failed${NC}"; echo "$RESULT" | grep -E "error:|NSLocalizedFailureReason" | head -3; return 1; } } cmd_stop_osx() { - local PLUM="plum" - ssh -o ConnectTimeout=5 "$PLUM" "echo ok" >/dev/null 2>&1 || { echo -e "${RED}Cannot reach plum${NC}"; return 1; } - PID=$(ssh "$PLUM" 'pgrep -f "Magic Civilization" 2>/dev/null | head -1') + local HOST="$OSX_HOST" + ssh -o ConnectTimeout=5 "$HOST" "echo ok" >/dev/null 2>&1 || { echo -e "${RED}Cannot reach $HOST${NC}"; return 1; } + PID=$(ssh "$HOST" 'pgrep -f "Magic Civilization" 2>/dev/null | head -1') [ -z "$PID" ] && { echo -e "${YELLOW}Not running.${NC}"; return 0; } - ssh "$PLUM" 'pkill -f "Magic Civilization"' 2>/dev/null + ssh "$HOST" 'pkill -f "Magic Civilization"' 2>/dev/null echo -e "${GREEN}Stopped (was PID $PID)${NC}" }