fix(@projects/@magic-civilization): 🐛 update runner setup docs for forgejo
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
31c08ed4c6
commit
69cbca4bef
16 changed files with 925 additions and 91 deletions
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
17
CLAUDE.md
17
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue