diff --git a/.gitea/RUNNER_SETUP.md b/.forgejo/RUNNER_SETUP.md similarity index 72% rename from .gitea/RUNNER_SETUP.md rename to .forgejo/RUNNER_SETUP.md index db6f6718..035f852b 100644 --- a/.gitea/RUNNER_SETUP.md +++ b/.forgejo/RUNNER_SETUP.md @@ -1,12 +1,12 @@ -# Gitea Actions Runner Setup +# Forgejo Actions Runner Setup -Prerequisites for the workflows under `.gitea/workflows/` to execute +Prerequisites for the workflows under `.forgejo/workflows/` to execute end-to-end on the self-hosted forge at `http://10.0.0.11:3000/magicciv/magicciv`. -Gitea Actions is drone-compatible; runners register via -[`act_runner`](https://gitea.com/gitea/act_runner). Files in -`.gitea/workflows/*.yml` are picked up automatically. +Forgejo Actions is drone-compatible; runners register via +[`forgejo-runner`](https://code.forgejo.org/forgejo/runner). Files in +`.forgejo/workflows/*.yml` are picked up automatically. --- @@ -21,7 +21,7 @@ Gitea Actions is drone-compatible; runners register via | `release.yml` — windows_build | `windows,x86_64` | *(unassigned)* | TODO(prerequisite) | Until a runner with matching labels registers, the corresponding jobs -queue indefinitely on Gitea. CI (push-to-main) only needs the apricot +queue indefinitely on Forgejo. CI (push-to-main) only needs the apricot runner; release (tag push) needs all four. --- @@ -34,39 +34,39 @@ One-time registration on apricot: ssh lilith@apricot.local cd ~ -# 1. Fetch act_runner (latest release binary for linux/amd64). -curl -L -o act_runner \ - https://gitea.com/gitea/act_runner/releases/download/nightly/act_runner-nightly-linux-amd64 -chmod +x act_runner -sudo mv act_runner /usr/local/bin/ +# 1. Fetch forgejo-runner (latest release binary for linux/amd64). +curl -L -o forgejo-runner \ + https://code.forgejo.org/forgejo/runner/releases/download/nightly/forgejo-runner-nightly-linux-amd64 +chmod +x forgejo-runner +sudo mv forgejo-runner /usr/local/bin/ -# 2. Create a runner token in Gitea: +# 2. Create a runner token in Forgejo: # http://10.0.0.11:3000/-/admin/actions/runners → "Create new runner" # Copy the registration token. -export GITEA_RUNNER_TOKEN='' +export FORGEJO_RUNNER_TOKEN='' # 3. Register. -mkdir -p ~/.local/share/act_runner -cd ~/.local/share/act_runner -act_runner register \ +mkdir -p ~/.local/share/forgejo-runner +cd ~/.local/share/forgejo-runner +forgejo-runner register \ --no-interactive \ --instance http://10.0.0.11:3000 \ - --token "$GITEA_RUNNER_TOKEN" \ + --token "$FORGEJO_RUNNER_TOKEN" \ --name apricot \ --labels "linux,apricot,gdext" # 4. Install as a systemd service (preferred) or run in a tmux session. # systemd unit (recommended): -cat <<'UNIT' | sudo tee /etc/systemd/system/act_runner.service +cat <<'UNIT' | sudo tee /etc/systemd/system/forgejo-runner.service [Unit] -Description=Gitea Actions runner +Description=Forgejo Actions runner After=network.target [Service] Type=simple User=lilith -WorkingDirectory=/home/lilith/.local/share/act_runner -ExecStart=/usr/local/bin/act_runner daemon +WorkingDirectory=/home/lilith/.local/share/forgejo-runner +ExecStart=/usr/local/bin/forgejo-runner daemon Restart=on-failure RestartSec=5s @@ -75,8 +75,8 @@ WantedBy=multi-user.target UNIT sudo systemctl daemon-reload -sudo systemctl enable --now act_runner -sudo systemctl status act_runner +sudo systemctl enable --now forgejo-runner +sudo systemctl status forgejo-runner ``` ### Toolchain prerequisites on apricot @@ -99,10 +99,10 @@ jobs to succeed: ### Secrets required on apricot -Set in Gitea at +Set in Forgejo at `http://10.0.0.11:3000/magicciv/magicciv/settings/actions/secrets`: -- `GITEA_RELEASE_TOKEN` — personal access token with `write:repository` +- `FORGEJO_RELEASE_TOKEN` — personal access token with `write:repository` scope for the `magicciv` org. Used by `release.yml` to create releases and upload assets via the REST API. @@ -119,24 +119,24 @@ tag push but will NOT block the other platforms' builds. On the target macOS host (Apple Silicon): ```bash -# 1. Install act_runner (arm64 build). -curl -L -o act_runner \ - https://gitea.com/gitea/act_runner/releases/download/nightly/act_runner-nightly-darwin-arm64 -chmod +x act_runner -sudo mv act_runner /usr/local/bin/ +# 1. Install forgejo-runner (arm64 build). +curl -L -o forgejo-runner \ + https://code.forgejo.org/forgejo/runner/releases/download/nightly/forgejo-runner-nightly-darwin-arm64 +chmod +x forgejo-runner +sudo mv forgejo-runner /usr/local/bin/ # 2. Register with macos,arm64 labels. -mkdir -p ~/Library/Application\ Support/act_runner -cd ~/Library/Application\ Support/act_runner -act_runner register \ +mkdir -p ~/Library/Application\ Support/forgejo-runner +cd ~/Library/Application\ Support/forgejo-runner +forgejo-runner register \ --no-interactive \ --instance http://10.0.0.11:3000 \ - --token "$GITEA_RUNNER_TOKEN" \ + --token "$FORGEJO_RUNNER_TOKEN" \ --name mac-release \ --labels "macos,arm64" # 3. Run as a launchd agent or in a persistent tmux. -act_runner daemon +forgejo-runner daemon ``` ### Toolchain prerequisites on the macOS runner @@ -174,23 +174,23 @@ macOS above. On the target Windows host (PowerShell, as admin): ```powershell -# 1. Install act_runner (windows amd64 build). +# 1. Install forgejo-runner (windows amd64 build). Invoke-WebRequest ` - -Uri "https://gitea.com/gitea/act_runner/releases/download/nightly/act_runner-nightly-windows-amd64.exe" ` - -OutFile "C:\Tools\act_runner.exe" + -Uri "https://code.forgejo.org/forgejo/runner/releases/download/nightly/forgejo-runner-nightly-windows-amd64.exe" ` + -OutFile "C:\Tools\forgejo-runner.exe" # 2. Register with windows,x86_64 labels. cd C:\Tools -.\act_runner.exe register ` +.\forgejo-runner.exe register ` --no-interactive ` --instance http://10.0.0.11:3000 ` - --token "$env:GITEA_RUNNER_TOKEN" ` + --token "$env:FORGEJO_RUNNER_TOKEN" ` --name win-release ` --labels "windows,x86_64" # 3. Install as a Windows service (NSSM or sc.exe). -sc.exe create act_runner binPath= "C:\Tools\act_runner.exe daemon" start= auto -sc.exe start act_runner +sc.exe create forgejo-runner binPath= "C:\Tools\forgejo-runner.exe daemon" start= auto +sc.exe start forgejo-runner ``` ### Toolchain prerequisites on the Windows runner @@ -224,9 +224,9 @@ workflow rather than letting CI drift red / slow: | Stage | Demotion target | |---|---| -| `cargo test --features gpu` | `.gitea/workflows/nightly.yml` (new file) | -| Full 10-seed T300 autoplay batch | `.gitea/workflows/nightly.yml` | -| GUT integration tests (if added) | `.gitea/workflows/nightly.yml` | +| `cargo test --features gpu` | `.forgejo/workflows/nightly.yml` (new file) | +| Full 10-seed T300 autoplay batch | `.forgejo/workflows/nightly.yml` | +| GUT integration tests (if added) | `.forgejo/workflows/nightly.yml` | The 1-seed T100 smoke batch stays on push-to-main CI — it's the minimum-viable determinism + no-stall check and must gate every commit. @@ -239,5 +239,5 @@ Objective p2-10 acceptance bullet: a Testwright watcher observes the runner and a failed `main` triggers a TTS alert via `mcp__speech-synthesis__synthesize` with personality `ravdess02`. That watcher is a separate process (not configured here) that polls the -Gitea REST API for commit statuses. See objective p2-10 for the +Forgejo REST API for commit statuses. See objective p2-10 for the watcher implementation plan. diff --git a/.gitea/workflows/ci.yml b/.forgejo/workflows/ci.yml similarity index 93% rename from .gitea/workflows/ci.yml rename to .forgejo/workflows/ci.yml index b11099f2..6bb2044a 100644 --- a/.gitea/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -2,10 +2,10 @@ # # Implements objective p2-10 (`.project/objectives/p2-10-regression-ci-gate.md`): # every push to `main` must pass the full regression suite on the self-hosted -# apricot runner before the commit is considered "green" on the Gitea commit page. +# apricot runner before the commit is considered "green" on the Forgejo commit page. # -# Runner registration instructions: `.gitea/RUNNER_SETUP.md`. -# Forge: http://10.0.0.11:3000/magicciv/magicciv (Gitea Actions, drone-compatible). +# Runner registration instructions: `.forgejo/RUNNER_SETUP.md`. +# Forge: http://10.0.0.11:3000/magicciv/magicciv (Forgejo Actions, drone-compatible). # # Budget: median completion <= 15 min. If a stage consistently blows this, # demote it to a nightly workflow (see RUNNER_SETUP.md § "Budget overruns"). @@ -31,8 +31,8 @@ env: jobs: regression: name: regression gate - # Self-hosted apricot runner — see .gitea/RUNNER_SETUP.md for registration. - # Labels must match act_runner config on apricot exactly. + # Self-hosted apricot runner — see .forgejo/RUNNER_SETUP.md for registration. + # Labels must match forgejo-runner config on apricot exactly. runs-on: [self-hosted, linux, apricot, gdext] timeout-minutes: 25 diff --git a/.gitea/workflows/release.yml b/.forgejo/workflows/release.yml similarity index 92% rename from .gitea/workflows/release.yml rename to .forgejo/workflows/release.yml index 73ced12d..4ecbb124 100644 --- a/.gitea/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -2,17 +2,17 @@ # # Implements objective p2-06 (`.project/objectives/p2-06-export-pipeline.md`): # pushing a `v*` tag builds Linux/macOS/Windows game archives plus the WASM -# guide bundle and publishes them as Gitea release assets on the tag's +# guide bundle and publishes them as Forgejo release assets on the tag's # release page. # -# Runner prerequisites (see `.gitea/RUNNER_SETUP.md`): +# Runner prerequisites (see `.forgejo/RUNNER_SETUP.md`): # - linux_build → self-hosted apricot (labels: linux, apricot, gdext) — EXISTS # - wasm_build → self-hosted apricot (same labels) — EXISTS # - macos_build → self-hosted mac (labels: macos, arm64) — TODO # - windows_build→ self-hosted win (labels: windows, x86_64) — TODO # # The macOS + Windows jobs are authored completely; they will simply sit -# queued on Gitea until a runner with the matching labels registers. +# queued on Forgejo until a runner with the matching labels registers. name: release @@ -88,7 +88,7 @@ jobs: # ────────────────────────────────────────────────────────────────── # MACOS # TODO(prerequisite): no macos,arm64 runner is registered yet. See - # .gitea/RUNNER_SETUP.md § "macOS runner" for the expected labels and + # .forgejo/RUNNER_SETUP.md § "macOS runner" for the expected labels and # the `rustup target add aarch64-apple-darwin` / Godot editor install # prerequisites. Until a runner registers, this job queues indefinitely. # ────────────────────────────────────────────────────────────────── @@ -145,7 +145,7 @@ jobs: # ────────────────────────────────────────────────────────────────── # WINDOWS # TODO(prerequisite): no windows,x86_64 runner is registered yet. See - # .gitea/RUNNER_SETUP.md § "Windows runner" for the expected labels + # .forgejo/RUNNER_SETUP.md § "Windows runner" for the expected labels # and the `rustup target add x86_64-pc-windows-msvc` / Godot editor # install prerequisites. # ────────────────────────────────────────────────────────────────── @@ -260,12 +260,12 @@ jobs: # ────────────────────────────────────────────────────────────────── # RELEASE - # Downloads all build artifacts and creates a Gitea release on the + # Downloads all build artifacts and creates a Forgejo release on the # tag, attaching each archive as a release asset. Notes are derived # from .project/CHANGELOG.md entries since the prior tag. # ────────────────────────────────────────────────────────────────── release: - name: publish gitea release + name: publish forgejo release needs: [linux_build, macos_build, windows_build, wasm_build] runs-on: [self-hosted, linux, apricot, gdext] timeout-minutes: 15 @@ -339,20 +339,20 @@ jobs: echo "Staged release assets:" ls -lh "$assets_dir" - - name: Create Gitea release + - name: Create Forgejo release env: # Supplied by the runner environment / org secrets — see RUNNER_SETUP.md. - GITEA_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }} - GITEA_HOST: http://10.0.0.11:3000 - GITEA_REPO: magicciv/magicciv + FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }} + FORGEJO_HOST: http://10.0.0.11:3000 + FORGEJO_REPO: magicciv/magicciv run: | set -euo pipefail tag="${{ steps.version.outputs.tag }}" version="${{ steps.version.outputs.version }}" notes_file="${{ steps.notes.outputs.notes_file }}" - if [ -z "${GITEA_TOKEN:-}" ]; then - echo "::error::GITEA_RELEASE_TOKEN secret is not set; see RUNNER_SETUP.md" + if [ -z "${FORGEJO_TOKEN:-}" ]; then + echo "::error::FORGEJO_RELEASE_TOKEN secret is not set; see RUNNER_SETUP.md" exit 1 fi @@ -364,17 +364,17 @@ jobs: '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')" release_json="$(curl -sS -X POST \ - -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ -H "Content-Type: application/json" \ -d "$create_payload" \ - "${GITEA_HOST}/api/v1/repos/${GITEA_REPO}/releases" || true)" + "${FORGEJO_HOST}/api/v1/repos/${FORGEJO_REPO}/releases" || true)" release_id="$(echo "$release_json" | jq -r '.id // empty')" if [ -z "$release_id" ]; then # Release already exists — fetch its id. release_id="$(curl -sS \ - -H "Authorization: token ${GITEA_TOKEN}" \ - "${GITEA_HOST}/api/v1/repos/${GITEA_REPO}/releases/tags/${tag}" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_HOST}/api/v1/repos/${FORGEJO_REPO}/releases/tags/${tag}" \ | jq -r '.id')" fi @@ -392,9 +392,9 @@ jobs: name="$(basename "$asset")" echo "Uploading $name ..." curl -sS -X POST \ - -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ -F "attachment=@${asset}" \ - "${GITEA_HOST}/api/v1/repos/${GITEA_REPO}/releases/${release_id}/assets?name=${name}" \ + "${FORGEJO_HOST}/api/v1/repos/${FORGEJO_REPO}/releases/${release_id}/assets?name=${name}" \ > /dev/null done diff --git a/.project/objectives/README.md b/.project/objectives/README.md index a1cb235a..36857a2b 100644 --- a/.project/objectives/README.md +++ b/.project/objectives/README.md @@ -10,10 +10,10 @@ | Status | Count | |---|---| -| ✅ done | 11 | -| 🟡 partial | 22 | +| ✅ done | 12 | +| 🟡 partial | 23 | | 🔴 stub | 0 | -| ❌ missing | 8 | +| ❌ missing | 6 | | ⚫ oos | 4 | | **total** | **45** | @@ -27,7 +27,7 @@ | [p0-04](p0-04-wonder-tracking.md) | ✅ done | World wonder tracking in PlayerState and score victory | — | 2026-04-17 | | [p0-05](p0-05-culture-and-borders.md) | ✅ done | Culture generation and border expansion | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | | [p0-06](p0-06-economy-integration.md) | 🟡 partial | Fold gold income / upkeep / improvement yields into turn loop | — | 2026-04-17 | -| [p0-07](p0-07-tech-research-costs.md) | 🟡 partial | Tech research costs and science pool pacing | — | 2026-04-17 | +| [p0-07](p0-07-tech-research-costs.md) | ✅ done | 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) | ✅ done | Game-completion stability — ≥7/10 seeds declare a winner | — | 2026-04-17 | @@ -40,7 +40,7 @@ | [p0-17](p0-17-wild-creature-lair-loop.md) | 🟡 partial | Wild creature and lair clearing loop | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | | [p0-18](p0-18-strategic-resource-gate.md) | ✅ done | Strategic resources gate unit production (empire ledger) | — | 2026-04-17 | | [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) | ❌ missing | GPU-accelerated MCTS rollouts for look-ahead decision-making | [warcouncil](../team-leads/warcouncil.md) | 2026-04-17 | +| [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 | ## P1 — Ship-readiness @@ -70,7 +70,7 @@ | [p2-07](p2-07-credits-screen.md) | ❌ missing | Credits screen accessible from main menu | — | 2026-04-17 | | [p2-08](p2-08-accessibility.md) | ❌ missing | Accessibility baseline — colorblind palette + keyboard navigation | — | 2026-04-17 | | [p2-09](p2-09-guide-web-deploy.md) | 🟡 partial | Player guide web app — deployed and up to date | — | 2026-04-17 | -| [p2-10](p2-10-regression-ci-gate.md) | ❌ missing | Automated regression CI gate on every push to main | [testwright](../team-leads/testwright.md) | 2026-04-17 | +| [p2-10](p2-10-regression-ci-gate.md) | 🟡 partial | Automated regression CI gate on every push to main | [testwright](../team-leads/testwright.md) | 2026-04-17 | | [p2-11](p2-11-version-about-screen.md) | ❌ missing | Version string + About screen | — | 2026-04-17 | | [p2-12](p2-12-magic-schools-oos.md) | ⚫ oos | Five magic schools (Life / Death / Chaos / Nature / Aether) — Game 2 | — | 2026-04-17 | | [p2-13](p2-13-archons-ascension-oos.md) | ⚫ oos | Archons + Arcane Ascension victory — Game 2 | — | 2026-04-17 | diff --git a/.project/objectives/p0-01-mcts-wiring.md b/.project/objectives/p0-01-mcts-wiring.md index c8a2377e..5822d17b 100644 --- a/.project/objectives/p0-01-mcts-wiring.md +++ b/.project/objectives/p0-01-mcts-wiring.md @@ -25,11 +25,14 @@ evidence: ## Acceptance -- ✓ `AiTurnBridge` delegates to MCTS when `AI_USE_MCTS=true` and falls back to `SimpleHeuristicAi` otherwise — `src/game/engine/src/modules/ai/ai_turn_bridge.gd` routes via env flag; GUT test `test_ai_turn_bridge_mcts.gd` (7 tests) covers flag routing, JSON shape, queue overrides, determinism. -- ~ 10-seed T300 batch (`AI_USE_MCTS=true`, 2026-04-17 via mcts-a3-dev): **victory rate 9/10 = 90%** (target ≥50% ✓); median TTV ≈T195 (target 200–350 band — 5 turns below floor, within tolerance but not strictly in-band). Seeds: p0 wins 6/10, p1 wins 3/10, incomplete 1/10. +- ✗ `AiTurnBridge` ALWAYS delegates to MCTS — no fallback, no feature flag. Commandment 5: "NEVER write fallbacks". Current implementation conditions on `AI_USE_MCTS=true` with `SimpleHeuristicAi` as the otherwise path; this is a **tech-debt violation** that must be removed. `SimpleHeuristicAi` may live on only as MCTS's internal rollout / default-policy component (invoked inside the tree search), never as a runtime substitute for the whole AI. If MCTS fails, the game fails loudly — not silently to a heuristic. +- ~ 10-seed T300 batch (MCTS forced, 2026-04-17 via mcts-a3-dev): **victory rate 9/10 = 90%** (target ≥50% ✓); median TTV ≈T195 (target 200–350 band — 5 turns below floor, within tolerance but not strictly in-band). Seeds: p0 wins 6/10, p1 wins 3/10, incomplete 1/10. Must be re-run after fallback removal to prove MCTS stands alone. - ✓ Determinism preserved — GUT test 7 in `test_ai_turn_bridge_mcts.gd` asserts same seed → same directive across repeated runs. -**Remaining to reach done:** warcouncil integrity guard (line 24) requires ai-verify Task #5 produce a byte-identical two-run diff artifact AND the median-TTV band question be resolved. Once those land, flip to done. +**Remaining to reach done:** +1. Remove the `AI_USE_MCTS` flag and the `SimpleHeuristicAi`-as-fallback branch from `ai_turn_bridge.gd`. MCTS becomes the only AI path. +2. Re-run the 10-seed T300 batch under the fail-fast path; cite winrate + TTV. +3. warcouncil integrity guard (line 24) requires ai-verify Task #5 produce a byte-identical two-run diff artifact. Once that lands, flip to done. ## Non-goals diff --git a/.project/objectives/p0-16-worker-improvement-loop.md b/.project/objectives/p0-16-worker-improvement-loop.md index 1000c063..b02a0a1b 100644 --- a/.project/objectives/p0-16-worker-improvement-loop.md +++ b/.project/objectives/p0-16-worker-improvement-loop.md @@ -20,11 +20,18 @@ Workers build farms, mines, hunting grounds that modify tile yields. Data JSON + ## Acceptance -- Every seed in a 10-seed T300 batch produces ≥5 worker improvements (checklist target). -- Improvement → tile-yield delta is deterministic (seeded) and visible in `tile_info_panel.show_tile` tooltip. -- Tech unlocks gate advanced improvements (e.g. `windmill` after `milling`); blocked improvements never appear in worker candidate list. -- GUT test: mock worker at a grassland tile → `apply_improvement("farm")` → get_yields increases by the farm's documented food delta. -- No player-facing script errors related to improvement placement in a 10-seed T300 run. +- ✗ Every seed in a 10-seed T300 batch produces ≥5 worker improvements (checklist target). Last measured: `loop8` showed some seeds at 0 (regression from `loop7` min=8). **Needs fresh 10-seed T300 batch under current apricot build.** +- ? Improvement → tile-yield delta is deterministic (seeded) and visible in `tile_info_panel.show_tile` tooltip. Renderer hook exists in `tile.gd`; tooltip visual not yet proof-screenshotted. +- ? Tech unlocks gate advanced improvements (e.g. `windmill` after `milling`); blocked improvements never appear in worker candidate list. Logic path lives in `simple_heuristic_ai.gd`; no dedicated GUT test confirms the gate. +- ✗ GUT test: mock worker at a grassland tile → `apply_improvement("farm")` → get_yields increases by the farm's documented food delta. Test file does not exist. +- ? No player-facing script errors related to improvement placement in a 10-seed T300 run. Needs fresh batch grep for `SCRIPT ERROR` + `improvement` co-occurrence. + +## Remaining to reach done + +1. Run a 10-seed T300 batch with current apricot build; count `worker_improvements` per seed from `turn_stats.jsonl` aggregate; assert min ≥ 5. +2. Author GUT unit test `test_worker_improvement_yield.gd` at `src/game/engine/tests/unit/` covering grassland + farm yield delta. +3. Author GUT unit test covering tech-gated improvement exclusion (e.g. worker at tile without `milling` never picks `windmill`). +4. Grep batch `script_errors.log` for improvement-related failures; resolve or cite zero-match. ## Non-goals diff --git a/.project/objectives/p0-20-gpu-mcts-rollouts.md b/.project/objectives/p0-20-gpu-mcts-rollouts.md index c6666fea..45f457a3 100644 --- a/.project/objectives/p0-20-gpu-mcts-rollouts.md +++ b/.project/objectives/p0-20-gpu-mcts-rollouts.md @@ -2,11 +2,12 @@ id: p0-20 title: GPU-accelerated MCTS rollouts for look-ahead decision-making priority: p0 -status: missing +status: partial scope: game1 owner: warcouncil updated_at: 2026-04-17 evidence: + - src/simulator/crates/mc-ai/src/abstract_state.rs - src/simulator/crates/mc-ai/src/mcts_tree.rs - src/simulator/crates/mc-turn/src/gpu/mod.rs - src/simulator/crates/mc-ai/src/game_state.rs diff --git a/.project/objectives/p2-06-export-pipeline.md b/.project/objectives/p2-06-export-pipeline.md index 57de1524..446f6485 100644 --- a/.project/objectives/p2-06-export-pipeline.md +++ b/.project/objectives/p2-06-export-pipeline.md @@ -8,6 +8,8 @@ updated_at: 2026-04-17 evidence: - src/game/export_presets.cfg - scripts/run/remote.sh + - .forgejo/workflows/release.yml + - .forgejo/RUNNER_SETUP.md --- ## Summary diff --git a/.project/objectives/p2-10-regression-ci-gate.md b/.project/objectives/p2-10-regression-ci-gate.md index 0965c950..9e9e7e51 100644 --- a/.project/objectives/p2-10-regression-ci-gate.md +++ b/.project/objectives/p2-10-regression-ci-gate.md @@ -2,20 +2,28 @@ id: p2-10 title: Automated regression CI gate on every push to main priority: p2 -status: missing +status: partial scope: game1 owner: testwright updated_at: 2026-04-17 evidence: + - .forgejo/workflows/ci.yml + - .forgejo/RUNNER_SETUP.md - tools/validate-game-data.py - tools/objectives-report.py - src/simulator/Cargo.toml +acceptance_audit: + workflow_authored: "✓ — .forgejo/workflows/ci.yml (119 lines, yaml-parseable). Trigger: push: branches: [main]. Single regression job runs cargo test --workspace (locked), gpu-feature tests (best-effort), gdlint, validate-game-data.py, objectives-report.py --check, headless GUT flatpak, 1-seed T100 smoke via tools/autoplay-batch.sh." + apricot_runner_registered: "✗ — deferred. RUNNER_SETUP.md § apricot documents forgejo-runner register command + systemd unit + toolchain prerequisites. User must run registration on first apricot session." + commit_status_visible: "✗ — unverifiable until runner registered and first push flows through. Workflow authored correctly to report status." + testwright_watcher_tts: "✗ — separate process, not part of the workflow. RUNNER_SETUP.md § Watcher describes it; not configured yet." + pipeline_budget_15min: "✗ — unmeasured until first run. 25-min timeout on the regression job is a hard cap; stages are parallelizable if this proves too tight." --- ## Summary This project ships via direct commits to `main` on a self-hosted forge -at `http://10.0.0.11:3000/magicciv/magicciv` (Gitea / Forgejo, port 3000). +at `http://10.0.0.11:3000/magicciv/magicciv` (Forgejo, port 3000). There is no PR workflow — `git log --oneline` shows zero "Merge pull request" commits, no feature branches, no review gate. Extensive tests exist (~740 Rust `#[test]`s, 34+ GUT tests, JSON schema validator, @@ -23,14 +31,14 @@ golden-vector harness) but nothing runs them on push. Every regression we've written a test for only catches the breakage when someone remembers to run the suite locally. -Gitea Actions (drone-compatible, files live in `.gitea/workflows/`) plus a +Forgejo Actions (drone-compatible, files live in `.forgejo/workflows/`) plus a self-hosted apricot runner can enforce the test suite on every push to `main`, matching the two-host workflow (CLAUDE.md: EDIT host commits, RUN host executes — apricot already is the RUN host). ## Acceptance -- `.gitea/workflows/ci.yml` exists and triggers on `push: branches: [main]`. +- `.forgejo/workflows/ci.yml` exists and triggers on `push: branches: [main]`. - Apricot registered as a self-hosted runner with labels `linux,apricot,gdext`; registration token lives in 1Password / env, not the repo. @@ -41,7 +49,7 @@ host executes — apricot already is the RUN host). - `python3 tools/objectives-report.py --check` (dashboard must stay in sync) - Headless GUT via `flatpak run ... --headless -s addons/gut/gut_cmdln.gd` - Seeded 1-seed T100 smoke batch that passes a minimum-viable checklist -- Commit status (green/red/pending) visible on the Gitea commit page. +- Commit status (green/red/pending) visible on the Forgejo commit page. - A Testwright watcher observes the runner; a failed main triggers TTS alert via `mcp__speech-synthesis__synthesize` with personality `ravdess02`. - Runtime budget: median pipeline completes in ≤15 minutes. If slower, @@ -51,7 +59,7 @@ host executes — apricot already is the RUN host). - PR-based gating — no PR workflow in use in this repo. - Full 10-seed T300 batch on every push (too slow; runs nightly via a - separate `.gitea/workflows/nightly.yml`). + separate `.forgejo/workflows/nightly.yml`). - Cross-platform runners — macOS/Windows runners belong to p2-06 (export pipeline), which may re-use the same apricot-registered runner pool for the Linux/WASM leg but is otherwise separate. diff --git a/CLAUDE.md b/CLAUDE.md index 52e8981c..67102e61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -270,6 +270,23 @@ games/ ## DX Tooling +### Forge — Forgejo (NOT Gitea) + +The self-hosted forge at `http://10.0.0.11:3000/magicciv/magicciv` is **Forgejo**, not Gitea. Probe confirms: `GET /api/v1/version` returns `{"version":"11.0.11+gitea-1.22.0"}` — that `+gitea-` suffix is how Forgejo reports API compatibility with upstream Gitea 1.22.0; pure Gitea would return just `1.22.0`. + +**Everywhere** in this repo — workflows, docs, runner setup, objective specs, commit messages — use Forgejo terminology: + +| Correct (Forgejo) | Incorrect (Gitea) | +|---|---| +| `.forgejo/workflows/*.yml` | `.gitea/workflows/*.yml` | +| `forgejo-runner` binary | `act_runner` | +| `code.forgejo.org/forgejo/runner/releases` | `gitea.com/gitea/act_runner/releases` | +| `FORGEJO_RUNNER_TOKEN`, `FORGEJO_TOKEN` | `GITEA_RUNNER_TOKEN`, `GITEA_TOKEN` | +| `forgejo-runner.service` (systemd) | `act_runner.service` | +| "Forgejo Actions" / "Forgejo release" | "Gitea Actions" / "Gitea release" | + +Forgejo is drone-compatible (like Gitea Actions) and keeps the `/api/v1/` REST surface identical, so the only things that differ are: branding, runner binary name, runner package URL, and the canonical workflows directory. CI/CD workflows must live under `.forgejo/workflows/`, not `.gitea/workflows/`. + ### Two-Host Workflow: EDIT host → RUN host Development is split across two hosts by **role**, not by specific machine. The edit host holds source-of-truth; the run host builds + executes simulations. The mapping from roles to actual hostnames is per-developer, read from shell env vars at runtime. diff --git a/scripts/dev-setup/linux.sh b/scripts/dev-setup/linux.sh index 6f698c79..c0f9075a 100755 --- a/scripts/dev-setup/linux.sh +++ b/scripts/dev-setup/linux.sh @@ -174,6 +174,58 @@ else fi fi +# ── Godot export templates (must match editor version exactly) ─────── +# Independent of --skip-godot: if flatpak Godot is already installed, we still +# need matching templates to export. Only skip when Godot itself is unavailable. +echo -e "${BLUE}[2c] Godot export templates${NC}" +if ! command -v flatpak &>/dev/null || ! flatpak info --user org.godotengine.Godot &>/dev/null 2>&1; then + skip "Godot export templates (flatpak Godot missing)" + SKIPPED+=("godot-templates") +else + # Discover flatpak Godot version + _godot_ver=$(flatpak info --user org.godotengine.Godot 2>/dev/null | awk '/Version:/{print $2}') + if [ -z "$_godot_ver" ]; then + warn "could not determine Godot version" + SKIPPED+=("godot-templates") + else + _tpl_dir="$HOME/.var/app/org.godotengine.Godot/data/godot/export_templates/${_godot_ver}.stable" + if [ -f "$_tpl_dir/linux_release.x86_64" ] && [ -f "$_tpl_dir/linux_debug.x86_64" ]; then + ok "Godot ${_godot_ver} templates ($_tpl_dir)" + ALREADY+=("godot-templates") + else + info "Downloading Godot ${_godot_ver} export templates..." + _tpz_url="https://github.com/godotengine/godot-builds/releases/download/${_godot_ver}-stable/Godot_v${_godot_ver}-stable_export_templates.tpz" + _tpz_tmp="$(mktemp -d)/templates.tpz" + _parent_dir="$HOME/.var/app/org.godotengine.Godot/data/godot/export_templates" + mkdir -p "$_parent_dir" + if curl -fL --retry 3 -o "$_tpz_tmp" "$_tpz_url" 2>&1 | tail -1; then + # .tpz is a zip; extracts to a top-level "templates/" folder. + _unzip_dir="$(mktemp -d)" + if unzip -q -o "$_tpz_tmp" -d "$_unzip_dir"; then + if [ -d "$_unzip_dir/templates" ]; then + rm -rf "$_tpl_dir" + mv "$_unzip_dir/templates" "$_tpl_dir" + ok "Godot ${_godot_ver} templates installed to $_tpl_dir" + INSTALLED+=("godot-templates") + else + fail "templates.tpz layout unexpected (no templates/ folder inside)" + FAILED+=("godot-templates") + fi + else + fail "unzip failed for $_tpz_tmp" + FAILED+=("godot-templates") + fi + rm -rf "$_unzip_dir" + else + fail "download failed: $_tpz_url" + warn "Install manually: Godot editor → Editor → Manage Export Templates → Download" + FAILED+=("godot-templates") + fi + rm -f "$_tpz_tmp" + fi + fi +fi + # ── System build prereqs (only on mutable distros) ─────────────────── echo -e "${BLUE}[2b] build prerequisites${NC}" if [ "$IS_ATOMIC" = "true" ]; then diff --git a/src/game/engine/tests/unit/ai/test_ai_personality_axes.gd b/src/game/engine/tests/unit/ai/test_ai_personality_axes.gd index 33d71304..1ffa6c6b 100644 --- a/src/game/engine/tests/unit/ai/test_ai_personality_axes.gd +++ b/src/game/engine/tests/unit/ai/test_ai_personality_axes.gd @@ -406,3 +406,7 @@ func test_high_aggression_clan_chases_enemy_out_of_range() -> void: assert_true(target_col > 0, "Blackhammer aggression=9: must chase east toward enemy at x=20, got col=%d" % target_col) + +# Axis-extreme (value=1 vs value=9) and saturation (all-0s, all-10s) tests +# live in test_ai_personality_axes_extremes.gd. Split to stay under the +# project-wide 500-line file cap in .gdlintrc (max-file-lines). diff --git a/src/game/engine/tests/unit/ai/test_ai_personality_axes_extremes.gd b/src/game/engine/tests/unit/ai/test_ai_personality_axes_extremes.gd new file mode 100644 index 00000000..7fca2ec4 --- /dev/null +++ b/src/game/engine/tests/unit/ai/test_ai_personality_axes_extremes.gd @@ -0,0 +1,367 @@ +extends GutTest +## Axis-extreme + saturation tests for simple_heuristic_ai.gd. +## +## Companion to test_ai_personality_axes.gd. This file isolates ONE axis +## at a time (all others pinned to 5 neutral) and tests value=1 vs value=9 +## to prove the axis is individually consumed at its decision site. Plus +## saturation/out-of-range edge cases (all-0s, all-10s, expansion=100) to +## guard against off-by-one errors and crashes on corrupted save state. + +const AiScript: GDScript = preload("res://engine/src/modules/ai/simple_heuristic_ai.gd") +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") +const CityScript: GDScript = preload("res://engine/src/entities/city.gd") +const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") + + +func before_all() -> void: + DataLoader.load_theme("age-of-dwarves") + + +func before_each() -> void: + GameState.players = [] + GameState.layers = [{"units": []}] + GameState.turn_number = 100 + + +func after_each() -> void: + GameState.players = [] + GameState.layers = [] + GameState.turn_number = 1 + + +# ── Factories ──────────────────────────────────────────────────────────── + + +func _make_player(idx: int) -> PlayerScript: + var p: PlayerScript = PlayerScript.new(idx, "P%d" % idx, "dwarf") + p.gold = 0 + p.happiness = 0 + p.strategic_axes = { + "aggression": 5, "expansion": 5, "production": 5, + "wealth": 5, "trade_willingness": 5, "grudge_persistence": 5, + } + return p + + +func _make_city(owner_idx: int, pos: Vector2i, turn_founded: int = 0) -> CityScript: + var c: CityScript = CityScript.new() + c.owner = owner_idx + c.position = pos + c.turn_founded = turn_founded + c.buildings = [] + c.production_queue = [] + c.has_bombarded = false + return c + + +func _make_warrior(owner_idx: int, pos: Vector2i, hp: int = 10) -> UnitScript: + var u: UnitScript = UnitScript.new() + u.owner = owner_idx + u.position = pos + u.hp = hp + u.max_hp = 10 + u.attack = 8 + u.ranged_attack = 0 + u.defense = 5 + u.movement_remaining = 2 + u.can_found_city = false + return u + + +func _make_axes(axis_name: String, value: int) -> Dictionary: + ## Six-axis dict with every axis at 5 (neutral) except `axis_name` at `value`. + var axes: Dictionary = { + "aggression": 5, "expansion": 5, "production": 5, + "wealth": 5, "trade_willingness": 5, "grudge_persistence": 5, + } + axes[axis_name] = value + return axes + + +# ═══════════════════════════════════════════════════════════════════════ +# AXIS-EXTREME FIXTURES — one axis at a time, value=1 vs value=9 +# ═══════════════════════════════════════════════════════════════════════ + + +# ── aggression axis: 1 vs 9 — both must act on nearby enemy ───────────── +# Anti-bug canary for the line-203 misread: aggression=9 must produce a +# non-empty action. If the axis silently collapsed to expansion or zero, +# downstream decision drift would show up in other axes' tests too. + + +func test_axis_aggression_1_vs_9_both_act_on_enemy() -> void: + var home: CityScript = _make_city(0, Vector2i(30, 30), 0) + var enemy: UnitScript = _make_warrior(1, Vector2i(20, 0)) + var empty_cities: Array[Vector2i] = [] + + var p_a: PlayerScript = _make_player(0) + p_a.strategic_axes = _make_axes("aggression", 1) + p_a.cities = [home] + var u_a: UnitScript = _make_warrior(0, Vector2i(0, 0)) + p_a.units = [u_a] + var q_a: PlayerScript = _make_player(1) + q_a.units = [enemy] + GameState.players = [p_a, q_a] + var action_a: Dictionary = AiScript._decide_military_action( + 0, u_a, p_a, [enemy], empty_cities, AiScript._resolve_personality(p_a) + ) + + GameState.players = [] + GameState.layers = [{"units": []}] + + var p_b: PlayerScript = _make_player(0) + p_b.strategic_axes = _make_axes("aggression", 9) + p_b.cities = [home] + var u_b: UnitScript = _make_warrior(0, Vector2i(0, 0)) + p_b.units = [u_b] + var q_b: PlayerScript = _make_player(1) + q_b.units = [enemy] + GameState.players = [p_b, q_b] + var action_b: Dictionary = AiScript._decide_military_action( + 0, u_b, p_b, [enemy], empty_cities, AiScript._resolve_personality(p_b) + ) + + assert_false(action_a.is_empty(), "aggression=1 must still act on nearby enemy") + assert_false(action_b.is_empty(), "aggression=9 must act on nearby enemy") + + +# ── expansion axis: 1 vs 9 differ on founder decision ────────────────── +# expansion=1 → target=1 city (clampi(0,1,5)=1), 1 city = target met, no founder. +# expansion=9 → target=3 cities, 1 city < target, queue founder. + + +func test_axis_expansion_1_vs_9_differ_on_founder() -> void: + var results: Array[String] = [] + for value: int in [1, 9]: + GameState.players = [] + GameState.layers = [{"units": []}] + var p: PlayerScript = _make_player(0) + p.strategic_axes = _make_axes("expansion", value) + var city: CityScript = _make_city(0, Vector2i(0, 0), 0) + city.add_building("walls") + p.cities = [city] + p.units = [ + _make_warrior(0, Vector2i(0, 0)), + _make_warrior(0, Vector2i(0, 1)), + ] + var q: PlayerScript = _make_player(1) + q.units = [] + GameState.players = [p, q] + GameState.layers = [{"units": p.units}] + + var prod: Dictionary = AiScript._decide_production( + 0, p, AiScript._resolve_personality(p) + ) + results.append(str(prod.get("item_id", ""))) + + assert_ne(results[0], "founder", + "expansion=1 with 1 city: must NOT queue founder (target met)") + assert_eq(results[1], "founder", + "expansion=9 with 1 city: must queue founder") + + +# ── production axis: 1 vs 9 differ on forge-first ────────────────────── +# production >= PRODUCTION_AXIS_BUILDING_BIAS=6 + mil>=2 fires forge-first. +# At 1 inactive → warrior; at 9 active → forge. + + +func test_axis_production_1_vs_9_differ_on_forge_first() -> void: + var results: Array[String] = [] + for value: int in [1, 9]: + GameState.players = [] + GameState.layers = [{"units": []}] + var p: PlayerScript = _make_player(0) + p.strategic_axes = _make_axes("production", value) + var city: CityScript = _make_city(0, Vector2i(0, 0), 0) + city.add_building("walls") + p.cities = [city] + p.units = [ + _make_warrior(0, Vector2i(0, 0)), + _make_warrior(0, Vector2i(0, 1)), + ] + var q: PlayerScript = _make_player(1) + q.units = [] + GameState.players = [p, q] + GameState.layers = [{"units": p.units}] + + var prod: Dictionary = AiScript._decide_production( + 0, p, AiScript._resolve_personality(p) + ) + results.append(str(prod.get("item_id", ""))) + + assert_ne(results[0], "forge", + "production=1: must NOT forge-first (axis under threshold)") + assert_eq(results[1], "forge", + "production=9: must forge-first past mil floor") + + +# ── wealth axis: 1 vs 9 differ on opportunistic rush-buy ─────────────── +# wealth >= WEALTH_AXIS_RUSH_THRESHOLD=6 + gold>=300 + enemy present fires. +# At 1 no rush-buy; at 9 rush-buy. Gated to T150 to avoid early-floor noise. + + +func test_axis_wealth_1_vs_9_differ_on_rush_buy() -> void: + var gained: Array[int] = [] + for value: int in [1, 9]: + GameState.players = [] + GameState.layers = [{"units": []}] + var p: PlayerScript = _make_player(0) + p.strategic_axes = _make_axes("wealth", value) + p.gold = 400 + p.cities = [_make_city(0, Vector2i(20, 20), 0)] + p.units = [_make_warrior(0, Vector2i(20, 20))] + var q: PlayerScript = _make_player(1) + q.units = [_make_warrior(1, Vector2i(0, 0))] + GameState.players = [p, q] + GameState.layers = [{"units": [p.units[0], q.units[0]]}] + GameState.turn_number = 150 + + var before: int = p.units.size() + AiScript.process_player(p) + gained.append(p.units.size() - before) + + assert_eq(gained[0], 0, + "wealth=1: must NOT opportunistic-rush-buy (axis under threshold)") + assert_true(gained[1] > 0, + "wealth=9: must rush-buy at gold>=300 with enemy present (gained=%d)" + % gained[1]) + + +# ── trade_willingness axis: 1 vs 9 round-trip ────────────────────────── +# Placeholder axis — no GDScript decision consumes it yet (reserved for +# p1-01 diplomacy). Verify it round-trips through _resolve_personality +# so the Rust bridge sees the right value once diplomacy lands. + + +func test_axis_trade_willingness_1_vs_9_roundtrip() -> void: + var p_low: PlayerScript = _make_player(0) + p_low.strategic_axes = _make_axes("trade_willingness", 1) + var axes_low: Dictionary = AiScript._resolve_personality(p_low) + assert_eq(int(axes_low.get("trade_willingness", -1)), 1, + "trade_willingness=1 must round-trip") + + var p_high: PlayerScript = _make_player(0) + p_high.strategic_axes = _make_axes("trade_willingness", 9) + var axes_high: Dictionary = AiScript._resolve_personality(p_high) + assert_eq(int(axes_high.get("trade_willingness", -1)), 9, + "trade_willingness=9 must round-trip") + + +# ── grudge_persistence axis: 1 vs 9 differ on retreat direction ──────── +# grudge >= GRUDGE_SUPPRESS_RETREAT_THRESHOLD=6 drops retreat-HP threshold +# from 0.4 to 0.25. At HP=3/10 (30%): grudge=1 retreats, grudge=9 stays. + + +func test_axis_grudge_persistence_1_vs_9_differ_on_retreat() -> void: + var home: CityScript = _make_city(0, Vector2i(20, 0), 0) + var enemy: UnitScript = _make_warrior(1, Vector2i(12, 0)) + var enemy_city_positions: Array[Vector2i] = [Vector2i(30, 30)] + + var cols: Array[int] = [] + for value: int in [1, 9]: + GameState.players = [] + var p: PlayerScript = _make_player(0) + p.strategic_axes = _make_axes("grudge_persistence", value) + p.cities = [home] + var wounded: UnitScript = _make_warrior(0, Vector2i(10, 0), 3) + p.units = [wounded] + var q: PlayerScript = _make_player(1) + q.units = [enemy] + GameState.players = [p, q] + + var action: Dictionary = AiScript._decide_military_action( + 0, wounded, p, [enemy], enemy_city_positions, + AiScript._resolve_personality(p) + ) + var target_col: int = 10 # Default to current position if no move + if not action.is_empty(): + target_col = int(action.get("target_col", 10)) + cols.append(target_col) + + # grudge=1 retreats WEST (away from enemy at x=12) → col < 10 + # grudge=9 advances EAST (toward enemy) → col >= 10 + assert_true(cols[1] > cols[0], + "grudge=9 must advance further east than grudge=1: got 1→%d 9→%d" + % [cols[0], cols[1]]) + + +# ═══════════════════════════════════════════════════════════════════════ +# SATURATION EDGE CASES +# ═══════════════════════════════════════════════════════════════════════ + + +# ── All axes at 0 — invalid/degenerate — must not crash ──────────────── +# 0 is outside the 1-10 canonical range. A corrupted save or hand-edited +# data could produce this. The heuristic must fall through gracefully. + + +func test_all_axes_zero_does_not_crash() -> void: + var p: PlayerScript = _make_player(0) + p.strategic_axes = { + "aggression": 0, "expansion": 0, "production": 0, + "wealth": 0, "trade_willingness": 0, "grudge_persistence": 0, + } + p.cities = [_make_city(0, Vector2i(0, 0), 0)] + p.units = [_make_warrior(0, Vector2i(0, 0))] + var q: PlayerScript = _make_player(1) + q.units = [] + GameState.players = [p, q] + GameState.layers = [{"units": p.units}] + + var actions: Array = AiScript.process_player(p) + assert_true(actions.size() >= 0, + "all-axes-0 must not crash process_player (got %d actions)" % actions.size()) + + +# ── All axes at 10 — saturated — must not crash ──────────────────────── +# 10 is outside the 1-9 canonical range but above. Tests guard against +# off-by-one errors in thresholds (e.g. `>= 9` vs `> 9`). + + +func test_all_axes_ten_does_not_crash() -> void: + var p: PlayerScript = _make_player(0) + p.strategic_axes = { + "aggression": 10, "expansion": 10, "production": 10, + "wealth": 10, "trade_willingness": 10, "grudge_persistence": 10, + } + p.gold = 1000 + p.cities = [_make_city(0, Vector2i(0, 0), 0)] + p.units = [_make_warrior(0, Vector2i(0, 0))] + var q: PlayerScript = _make_player(1) + q.units = [_make_warrior(1, Vector2i(10, 10))] + GameState.players = [p, q] + GameState.layers = [{"units": [p.units[0], q.units[0]]}] + GameState.turn_number = 200 + + var actions: Array = AiScript.process_player(p) + assert_true(actions.size() >= 0, + "all-axes-10 must not crash process_player") + + +# ── Expansion at 100 — clamp must hold, no runaway founder ───────────── +# `clampi(expansion_axis / 3, 1, 5)` caps the founder target at 5 cities. + + +func test_expansion_axis_extreme_clamps_founder_target() -> void: + var p: PlayerScript = _make_player(0) + p.strategic_axes = _make_axes("expansion", 100) + var city: CityScript = _make_city(0, Vector2i(0, 0), 0) + city.add_building("walls") + p.cities = [city] + # Add 4 more cities → 5 total = clamp cap + for i: int in 4: + p.cities.append(_make_city(0, Vector2i(10 + i, 0), 0)) + p.units = [ + _make_warrior(0, Vector2i(0, 0)), + _make_warrior(0, Vector2i(0, 1)), + ] + var q: PlayerScript = _make_player(1) + q.units = [] + GameState.players = [p, q] + GameState.layers = [{"units": p.units}] + + var prod: Dictionary = AiScript._decide_production( + 0, p, AiScript._resolve_personality(p) + ) + assert_ne(prod.get("item_id", ""), "founder", + "expansion=100 clamped to cap=5: must NOT queue founder at 5 cities") diff --git a/src/game/engine/tests/unit/ai/test_simple_heuristic_ai_branches.gd b/src/game/engine/tests/unit/ai/test_simple_heuristic_ai_branches.gd new file mode 100644 index 00000000..eda92f75 --- /dev/null +++ b/src/game/engine/tests/unit/ai/test_simple_heuristic_ai_branches.gd @@ -0,0 +1,367 @@ +extends GutTest +## Branch-coverage companion to test_simple_heuristic_ai.gd. +## +## Covers decision branches in simple_heuristic_ai.gd not exercised by the +## main file: founder flee/found, tech selection, city bombard gating, +## military-unit fallback, null-safety + empty-state edge cases, research +## scheduling, happiness-building priority, production fallback chain, and +## lone-defender garrison hold. +## +## Split off from test_simple_heuristic_ai.gd to stay under .gdlintrc +## max-file-lines=500. + +const AiScript: GDScript = preload("res://engine/src/modules/ai/simple_heuristic_ai.gd") +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") +const CityScript: GDScript = preload("res://engine/src/entities/city.gd") +const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") + + +func before_all() -> void: + DataLoader.load_theme("age-of-dwarves") + + +func before_each() -> void: + GameState.players = [] + GameState.layers = [{"units": []}] + GameState.turn_number = 50 + + +func after_each() -> void: + GameState.players = [] + GameState.layers = [] + GameState.turn_number = 1 + + +# ── Factories ──────────────────────────────────────────────────────────── + + +func _make_player(idx: int) -> PlayerScript: + var p: PlayerScript = PlayerScript.new(idx, "P%d" % idx, "dwarf") + p.gold = 0 + p.happiness = 0 + p.strategic_axes = {"expansion": 3, "production": 3, "wealth": 3} + return p + + +func _make_city(owner_idx: int, pos: Vector2i, turn_founded: int = 0) -> CityScript: + var c: CityScript = CityScript.new() + c.owner = owner_idx + c.position = pos + c.turn_founded = turn_founded + c.buildings = [] + c.production_queue = [] + c.has_bombarded = false + return c + + +func _make_warrior(owner_idx: int, pos: Vector2i, hp: int = 10) -> UnitScript: + var u: UnitScript = UnitScript.new() + u.owner = owner_idx + u.position = pos + u.hp = hp + u.max_hp = 10 + u.attack = 8 + u.ranged_attack = 0 + u.defense = 5 + u.movement_remaining = 2 + u.can_found_city = false + return u + + +func _make_founder(owner_idx: int, pos: Vector2i) -> UnitScript: + var u: UnitScript = UnitScript.new() + u.owner = owner_idx + u.position = pos + u.hp = 10 + u.max_hp = 10 + u.attack = 0 + u.ranged_attack = 0 + u.defense = 3 + u.movement_remaining = 2 + u.can_found_city = true + return u + + +# ── Test: founder far from enemy and own cities founds a city ──────────── +# With no enemies and no own cities, _decide_founder_action should settle +# (or at least produce a settlement-intent action). + + +func test_founder_founds_city_when_isolated() -> void: + var p0: PlayerScript = _make_player(0) + var p1: PlayerScript = _make_player(1) + + var founder: UnitScript = _make_founder(0, Vector2i(10, 10)) + p0.units = [founder] + p0.cities = [] # dist_own = INF_DISTANCE + p1.units = [] + + GameState.players = [p0, p1] + GameState.layers = [{"units": [founder]}] + + var action: Dictionary = AiScript._decide_founder_action(0, founder, p0, []) + assert_false(action.is_empty(), "Isolated founder: must produce action") + # Tile quality may gate to move instead of found if score is 0 — accept either. + var valid_types: Array[String] = ["found_city", "move_unit"] + assert_true(action.get("type", "") in valid_types, + "Isolated founder action must be found_city or move_unit, got %s" + % str(action.get("type", ""))) + + +# ── Test: founder flees from adjacent enemy ───────────────────────────── + + +func test_founder_flees_from_adjacent_enemy() -> void: + var p0: PlayerScript = _make_player(0) + var p1: PlayerScript = _make_player(1) + + var founder: UnitScript = _make_founder(0, Vector2i(10, 10)) + p0.units = [founder] + p0.cities = [] + + # Enemy adjacent (dist=1) → FOUND_MIN_DIST_ENEMY=1 requires dist>1. + var enemy: UnitScript = _make_warrior(1, Vector2i(11, 10)) + p1.units = [enemy] + + GameState.players = [p0, p1] + GameState.layers = [{"units": [founder, enemy]}] + + var action: Dictionary = AiScript._decide_founder_action(0, founder, p0, [enemy]) + assert_false(action.is_empty(), "Threatened founder: must produce action") + assert_eq(action.get("type", ""), "move_unit", + "Founder with adjacent enemy must flee, not found") + + +# ── Test: _pick_next_tech returns a valid tech id ──────────────────────── + + +func test_pick_next_tech_returns_valid_id_for_new_player() -> void: + var p0: PlayerScript = _make_player(0) + p0.researched_techs = [] + GameState.players = [p0] + + var tech_id: String = AiScript._pick_next_tech(p0) + assert_true(tech_id is String, "_pick_next_tech must return a String") + if not tech_id.is_empty(): + var tech_data: Dictionary = DataLoader.get_tech(tech_id) + assert_false(tech_data.is_empty(), + "_pick_next_tech returned %s but DataLoader doesn't know it" % tech_id) + + +# ── Test: _pick_next_tech skips already-researched techs ──────────────── + + +func test_pick_next_tech_skips_researched() -> void: + var p0: PlayerScript = _make_player(0) + p0.researched_techs = [] + GameState.players = [p0] + + var first: String = AiScript._pick_next_tech(p0) + if first.is_empty(): + pending("No techs available in data pack for pick-next-tech test") + return + + p0.researched_techs = [first] + var second: String = AiScript._pick_next_tech(p0) + assert_ne(second, first, + "_pick_next_tech must return a different tech once first is researched") + + +# ── Test: _decide_city_bombard returns empty when no enemy in range ───── + + +func test_city_bombard_empty_when_no_enemy_in_range() -> void: + var p0: PlayerScript = _make_player(0) + var p1: PlayerScript = _make_player(1) + + var city: CityScript = _make_city(0, Vector2i(0, 0), 0) + p0.cities = [city] + var enemy: UnitScript = _make_warrior(1, Vector2i(10, 10)) # > bombard_range + p1.units = [enemy] + + GameState.players = [p0, p1] + GameState.layers = [{"units": [enemy]}] + + var action: Dictionary = AiScript._decide_city_bombard(0, city, p0) + assert_true(action.is_empty(), + "City bombard: must return empty when no enemy in range") + + +# ── Test: _decide_city_bombard fires on adjacent enemy ────────────────── + + +func test_city_bombard_fires_on_adjacent_enemy() -> void: + var p0: PlayerScript = _make_player(0) + var p1: PlayerScript = _make_player(1) + + var city: CityScript = _make_city(0, Vector2i(0, 0), 0) + p0.cities = [city] + var enemy: UnitScript = _make_warrior(1, Vector2i(1, 0)) # dist=1 + p1.units = [enemy] + + GameState.players = [p0, p1] + GameState.layers = [{"units": [enemy]}] + + var action: Dictionary = AiScript._decide_city_bombard(0, city, p0) + assert_false(action.is_empty(), + "City bombard: must fire on adjacent enemy") + assert_eq(action.get("type", ""), "city_bombard", + "Bombard action type") + assert_eq(action.get("target_col", -1), 1, + "Bombard must target enemy col=1") + + +# ── Test: warrior fallback from _pick_buildable_military_unit_id ──────── + + +func test_pick_buildable_military_returns_warrior_when_available() -> void: + var p0: PlayerScript = _make_player(0) + var city: CityScript = _make_city(0, Vector2i(0, 0), 0) + p0.cities = [city] + GameState.players = [p0] + + var unit_id: String = AiScript._pick_buildable_military_unit_id(city, p0) + assert_eq(unit_id, "warrior", + "Ungated baseline: _pick_buildable_military_unit_id must return 'warrior'") + + +# ── Test: process_player is a no-op on null player ────────────────────── + + +func test_process_player_safe_on_null() -> void: + var actions: Array = AiScript.process_player(null) + assert_true(actions.is_empty(), + "process_player(null) must return empty array, not crash") + + +# ── Test: process_player on player with no cities/units/gold ──────────── + + +func test_process_player_empty_player_state() -> void: + var p0: PlayerScript = _make_player(0) + var p1: PlayerScript = _make_player(1) + p0.cities = [] + p0.units = [] + p0.gold = 0 + p1.cities = [] + p1.units = [] + GameState.players = [p0, p1] + GameState.layers = [{"units": []}] + + var actions: Array = AiScript.process_player(p0) + assert_true(actions.size() >= 0, + "process_player(empty state): must not crash") + + +# ── Test: process_player sets research when idle ──────────────────────── + + +func test_process_player_sets_research_when_idle() -> void: + var p0: PlayerScript = _make_player(0) + var p1: PlayerScript = _make_player(1) + var city: CityScript = _make_city(0, Vector2i(0, 0), 0) + p0.cities = [city] + p0.units = [] + p0.researching = "" + GameState.players = [p0, p1] + GameState.layers = [{"units": []}] + + AiScript.process_player(p0) + if AiScript._pick_next_tech(p0) != "": + assert_false(p0.researching.is_empty(), + "process_player must set researching when idle and techs available") + + +# ── Test: happiness building picked when player is unhappy ────────────── +# Requires turn > 80 so early_mil_floor drops to 0, otherwise Priority 0 +# (military-floor) fires before Priority 2 (happiness). + + +func test_happiness_building_picked_when_unhappy() -> void: + var p0: PlayerScript = _make_player(0) + var p1: PlayerScript = _make_player(1) + + var city: CityScript = _make_city(0, Vector2i(0, 0), 0) + city.add_building("walls") + p0.cities = [city] + p0.happiness = -3 + # Four defenders so Priority 0 early-floor is saturated regardless of turn + p0.units = [ + _make_warrior(0, Vector2i(0, 0)), + _make_warrior(0, Vector2i(0, 1)), + _make_warrior(0, Vector2i(0, 2)), + _make_warrior(0, Vector2i(0, 3)), + ] + p1.units = [] + GameState.players = [p0, p1] + GameState.layers = [{"units": p0.units}] + GameState.turn_number = 100 + + var hb_id: String = AiScript._pick_happiness_building_id(city, p0) + if hb_id.is_empty(): + pending("No happiness building in data pack; skipping") + return + + # Fixture sanity — happiness and can_build must agree at decide time + assert_true(p0.happiness < 0, + "Fixture: p0.happiness must be < 0 (got %d)" % p0.happiness) + assert_true(city.can_build(hb_id, p0), + "Fixture: city.can_build('%s', p0) must be true" % hb_id) + + var prod: Dictionary = AiScript._decide_production(0, p0, p0.strategic_axes) + assert_eq(prod.get("item_type", ""), "building", + "Unhappy player must build a building (got %s)" % str(prod)) + assert_eq(prod.get("item_id", ""), hb_id, + "Unhappy player must build '%s' (got %s)" + % [hb_id, str(prod.get("item_id", ""))]) + + +# ── Test: production fallback chain — empty state still picks something ─ + + +func test_decide_production_no_gold_no_units_single_city() -> void: + var p0: PlayerScript = _make_player(0) + var p1: PlayerScript = _make_player(1) + + var city: CityScript = _make_city(0, Vector2i(0, 0), 0) + p0.cities = [city] + p0.units = [] + p0.gold = 0 + p1.units = [] + GameState.players = [p0, p1] + GameState.layers = [{"units": []}] + + var prod: Dictionary = AiScript._decide_production(0, p0, p0.strategic_axes) + assert_false(prod.is_empty(), + "Empty player state: _decide_production must still pick something") + assert_true(prod.has("item_type"), "Production dict must have item_type") + assert_true(prod.has("item_id"), "Production dict must have item_id") + + +# ── Test: garrison lone defender holds home tile ──────────────────────── + + +func test_garrison_lone_defender_holds_home_tile() -> void: + var p0: PlayerScript = _make_player(0) + var p1: PlayerScript = _make_player(1) + + var city: CityScript = _make_city(0, Vector2i(0, 0), 0) + p0.cities = [city] + var lone: UnitScript = _make_warrior(0, Vector2i(0, 0)) + p0.units = [lone] + var enemy: UnitScript = _make_warrior(1, Vector2i(15, 0)) # far away + p1.units = [enemy] + GameState.players = [p0, p1] + + var enemy_units: Array = [enemy] + var enemy_city_positions: Array[Vector2i] = [] + var personality: Dictionary = { + "aggression": 0, "expansion": 3, "production": 3, "wealth": 3, + "trade_willingness": 3, "grudge_persistence": 3, + } + + var action: Dictionary = AiScript._decide_military_action( + 0, lone, p0, enemy_units, enemy_city_positions, personality + ) + assert_true(action.is_empty(), + "Garrison: lone defender on home city with distant enemy must hold") diff --git a/src/simulator/api-gdext/src/ai.rs b/src/simulator/api-gdext/src/ai.rs index d29c2ff8..236359c2 100644 --- a/src/simulator/api-gdext/src/ai.rs +++ b/src/simulator/api-gdext/src/ai.rs @@ -321,6 +321,8 @@ mod tests { city_improvements: vec![vec![], vec![]], city_ecology: vec![CityEcology::default(); 2], tech_state: None, + science_pool: 0, + player_tech: None, science_yield: 0, units: vec![MapUnit { col: 0, row: 0, hp: 10, max_hp: 10, diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 93f3d3b3..92c450b2 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -1822,6 +1822,8 @@ impl GdGameState { city_improvements: vec![Vec::new()], city_ecology: vec![Default::default()], tech_state: None, + science_pool: 0, + player_tech: None, science_yield: 0, units, city_positions: vec![(city_col, city_row)], @@ -1935,6 +1937,8 @@ impl GdGameState { city_improvements: Vec::new(), city_ecology: Vec::new(), tech_state: None, + science_pool: 0, + player_tech: None, science_yield: 0, units: Vec::new(), city_positions: Vec::new(),