fix(@projects/@magic-civilization): 🐛 update runner setup docs for forgejo

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 02:10:56 -07:00
parent 31c08ed4c6
commit 69cbca4bef
16 changed files with 925 additions and 91 deletions

View file

@ -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='<paste-token-from-ui>'
export FORGEJO_RUNNER_TOKEN='<paste-token-from-ui>'
# 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.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(),