sim: Pitchwell match foundation (open/seed/T1/HQ) + smoke

This commit is contained in:
Hernâni Marques 2026-09-14 01:24:37 +02:00
parent 7358d1ce1a
commit bd5ade8ec4
No known key found for this signature in database
7 changed files with 708 additions and 41 deletions

View file

@ -1,16 +1,9 @@
#!/usr/bin/env bash
# Headless Pitchwell smoke — no Godot GUI. docs/GAMEPLAY.md: 20 Hz sim, seep +0.4/s.
# Headless Pitchwell smoke — no Godot GUI. Uses game.sim.match (20 Hz).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
N_TICKS="${1:-40}"
HZ=20
# GAMEPLAY.md Economy: Sump Wells slow seep (+0.4/s while claimed)
SEEP_PER_S=0.4
# GAMEPLAY.md Match open: HQ + 4 workers, 500 Pitch start
START_PITCH=500
DT="$(python3 -c "print(1.0/${HZ})")"
STUB="${ROOT}/assets/cinematics/first-contact/out/first-contact-stub.mp4"
fail() {
@ -18,7 +11,6 @@ fail() {
exit 1
}
# Optional cinematic probe (GAMEPLAY.md: briefings ≥1280×720)
if [[ -f "${STUB}" ]]; then
if command -v ffprobe >/dev/null 2>&1; then
wh="$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 "${STUB}" || true)"
@ -31,44 +23,43 @@ if [[ -f "${STUB}" ]]; then
fi
fi
# Pure-Python 20 Hz tick loop: match open — HQ exists, claimed well seeps Pitch
OUT="$(
N_TICKS="${N_TICKS}" HZ="${HZ}" SEEP_PER_S="${SEEP_PER_S}" START_PITCH="${START_PITCH}" DT="${DT}" python3 - <<'PY'
N_TICKS="${N_TICKS}" python3 - <<'PY'
import os, sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] if "__file__" in dir() else Path.cwd()
# when stdin script, cwd is ROOT from caller
sys.path.insert(0, str(Path.cwd()))
from game.sim.catalog import FACTION_STAFF, HZ, START_PITCH
from game.sim.match import open_match
n = int(os.environ["N_TICKS"])
hz = int(os.environ["HZ"])
seep = float(os.environ["SEEP_PER_S"])
pitch = float(os.environ["START_PITCH"])
dt = float(os.environ["DT"])
hq_alive = True
well_claimed = True # conceptual: refinery radius holds seep (GAMEPLAY.md)
if n < 1:
print("bad N_TICKS", n, file=sys.stderr)
sys.exit(2)
for t in range(n):
if not hq_alive:
print("HQ dead mid-sim", file=sys.stderr)
sys.exit(1)
if well_claimed:
pitch += seep * dt # +0.4 Pitch/s → +0.02/tick at 20 Hz
# Expect: start 500 + N*(0.4/20)
expect = float(os.environ["START_PITCH"]) + n * (seep / hz)
# float tolerance
if abs(pitch - expect) > 1e-6:
print(f"pitch {pitch} != expect {expect}", file=sys.stderr)
m = open_match()
staff = m.side(FACTION_STAFF)
# claim well via finished font so seep applies (match open alone has neutral well)
r = m.enqueue_building(FACTION_STAFF, "pitch_font", well_index=0)
if r != "ok":
print("enqueue font", r, file=sys.stderr)
sys.exit(1)
if not hq_alive:
# finish 15s build
m.tick_hz(int(15 * HZ) + 1)
if not m.wells[0].claimed:
print("well not claimed", file=sys.stderr)
sys.exit(1)
before = staff.pitch
m.tick_hz(n)
# Staff harvest 0.85 * 0.4/s
expect_gain = n * (0.4 / HZ) * 0.85
got = staff.pitch - before
if abs(got - expect_gain) > 1e-6:
print(f"pitch gain {got} != expect {expect_gain}", file=sys.stderr)
sys.exit(1)
if not staff.hq_alive:
print("HQ missing", file=sys.stderr)
sys.exit(1)
print(f"ticks={n} hz={hz} pitch={pitch:.4f} hq=1 seep={seep}/s")
print(f"ticks={n} hz={HZ} pitch={staff.pitch:.4f} hq=1 gain={got:.4f}")
PY
)" || fail "sim ${OUT}"
)" || fail "sim"
echo "SMOKE PASS headless ${OUT}"
exit 0

103
scripts/smoke/match_smoke.py Executable file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Match open → seed → seep → T1 → HQ kill — startable foundation (GAMEPLAY.md v0)."""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from game.sim.catalog import FACTION_HOST, FACTION_STAFF, HZ, START_PITCH
from game.sim.match import MatchPhase, open_match, seed_v0_path, train_t1_melee
def main() -> int:
m = open_match()
staff = m.side(FACTION_STAFF)
host = m.side(FACTION_HOST)
if staff.pitch != START_PITCH or host.pitch != START_PITCH:
print(f"FAIL: start pitch {staff.pitch}/{host.pitch}")
return 1
if len(staff.units) != 4 or len(host.units) != 4:
print("FAIL: want 4 workers per side")
return 1
if not staff.hq_alive or not host.hq_alive:
print("FAIL: both HQs must be alive at open")
return 1
notes = seed_v0_path(m, FACTION_STAFF)
if any(not n.endswith(":ok") for n in notes):
print(f"FAIL: seed {notes}")
return 1
# Serial queue: Font 15 + Cot 12 + Yard 20 = 47s; start 500 - 380 = 120 left
m.tick_hz(int(47 * HZ) + 2)
if not staff.has_building("pitch_font"):
print("FAIL: pitch_font missing after seed")
return 1
if 0 not in staff.claimed_wells or not m.wells[0].claimed:
print("FAIL: well not claimed by Staff Font")
return 1
if not staff.has_producer():
print("FAIL: coven_yard missing")
return 1
if staff.pop_cap != 18:
print(f"FAIL: pop_cap want 18 (start 10 + cot 8) got {staff.pop_cap}")
return 1
pitch_before_seep = staff.pitch
m.tick_hz(int(10 * HZ)) # Staff harvest 15% on seep
if staff.pitch <= pitch_before_seep:
print(f"FAIL: seep did not raise pitch ({pitch_before_seep} -> {staff.pitch})")
return 1
# Afford one Acolyte from leftover start pitch + seep
one = train_t1_melee(m, FACTION_STAFF, n=1)
if one != ["acolyte:ok"]:
print(f"FAIL: train one {one} pitch={staff.pitch}")
return 1
m.tick_hz(int(8 * HZ) + 2)
if not any(u.spec_id == "acolyte" and u.alive for u in staff.units):
print("FAIL: acolyte not trained")
return 1
# Offline cheat for army mass (GAMEPLAY.md cheats offline-only)
m.grant_pitch(FACTION_STAFF, 500.0)
train = train_t1_melee(m, FACTION_STAFF, n=4)
if any(not t.endswith(":ok") for t in train):
print(f"FAIL: train army {train}")
return 1
m.tick_hz(int(4 * 8 * HZ) + 2)
acolytes = [u for u in staff.units if u.spec_id == "acolyte" and u.alive]
if len(acolytes) < 5:
print(f"FAIL: want >=5 acolytes got {len(acolytes)}")
return 1
m.order_attack_hq(FACTION_STAFF)
# 5 * 4 dps = 20 HP/s → 5000 HP ≈ 250s
for _ in range(HZ * 300):
if m.phase == MatchPhase.ENDED:
break
m.tick_hz(1)
if m.phase != MatchPhase.ENDED or m.winner != FACTION_STAFF:
print(f"FAIL: expected staff win got phase={m.phase} winner={m.winner}")
print(m.snapshot())
return 1
if host.hq_alive:
print("FAIL: host HQ still alive")
return 1
print(
f"PASS: open+seed+seep+T1 HQ kill t={m.sim_t:.1f}s "
f"staff_pitch={staff.pitch:.1f} ticks={m.tick_i}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -13,6 +13,7 @@ run() {
[[ -x scripts/smoke/headless_smoke.sh ]] && run scripts/smoke/headless_smoke.sh
[[ -x scripts/smoke/ip_fence_check.sh ]] && run scripts/smoke/ip_fence_check.sh
[[ -f scripts/smoke/economy_smoke.py ]] && run python3 scripts/smoke/economy_smoke.py
[[ -f scripts/smoke/match_smoke.py ]] && run python3 scripts/smoke/match_smoke.py
if [[ "$fail" -eq 0 ]]; then
echo "RUN_ALL PASS"