feat(@projects/@magic-civilization): update objective completion statuses

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 01:09:55 -07:00
parent 38c5305787
commit 959576c58c
8 changed files with 362 additions and 137 deletions

View file

@ -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 T8T10 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

View file

@ -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<String, u32>` field and two `MapUnit` struct initializers missing `held_resources: Vec<String>` — 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

View file

@ -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.

View file

@ -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=<turn>` 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

View file

@ -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:

View file

@ -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

170
run
View file

@ -1,6 +1,9 @@
#!/usr/bin/env bash
# Magic Civilization — Task Runner
# Usage: ./run <command> [args...]
#
# Naming convention: `<verb>` for global actions; `<verb>:<target>` for subcommands.
# Examples: lint / lint:rust, build / build:wasm, install:osx, smoke:linux.
set -uo pipefail
@ -18,26 +21,26 @@ usage() {
echo "Usage: ./run <command> [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 <cmd> Sprite generation pipeline"
echo " setup Install/verify all dev dependencies"
echo " tools:spritegen <cmd> 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:-<none>}${NC}"; echo "Available: osx, iphone, sim, android"; exit 1 ;;
esac
[ -n "$TARGET" ] || { echo -e "${RED}install requires a target (use install:<target>)${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:<target>)${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:-<none>}${NC}"; exit 1 ;;
esac
SUB="${1:-}"; shift 2>/dev/null || true
[ -n "$SUB" ] || { echo -e "${RED}tools requires a subcommand (use tools:<sub>)${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 ;;

View file

@ -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 <<REMOTE_BUILD_CMD
echo -e "${YELLOW}[3/4] Building on $HOST with xcodebuild...${NC}"
BUILD_RESULT=$(ssh "$HOST" bash <<REMOTE_BUILD_CMD
set -e
cd ~/MagicCiv_iOS_Build
xcodebuild -project MagicCivilization.xcodeproj -scheme MagicCivilization \
@ -142,7 +154,7 @@ REMOTE_BUILD_CMD
echo -e "${GREEN} ✓ Build succeeded${NC}"
echo -e "${YELLOW}[4/4] Installing to device...${NC}"
INSTALL_RESULT=$(ssh "$PLUM" bash <<'REMOTE_DEVICE_INSTALL'
INSTALL_RESULT=$(ssh "$HOST" bash <<'REMOTE_DEVICE_INSTALL'
set -e
APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData -name "MagicCivilization.app" \
\( -path "*/Release-iphoneos/*" -o -path "*/Debug-iphoneos/*" \) 2>/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/<binary> 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 <<REMOTE_LAUNCH
cd "\$HOME/MagicCiv"
export WAYLAND_DISPLAY="\${WAYLAND_DISPLAY:-wayland-0}"
export XDG_RUNTIME_DIR="\${XDG_RUNTIME_DIR:-/run/user/\$(id -u)}"
export DISPLAY="\${DISPLAY:-:0}"
nohup "./$BIN_NAME" >/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}"
}