diff --git a/game/sim/__init__.py b/game/sim/__init__.py index a74850a..56311cd 100644 --- a/game/sim/__init__.py +++ b/game/sim/__init__.py @@ -1,6 +1,16 @@ -"""Godot-less Pitchwell sim stubs.""" +"""Godot-less Pitchwell sim — economy + startable match stub.""" -from .pitch_economy import Economy, SumpWell, Worker, START_PITCH, WORKER_LOAD, WELL_CAPACITY, SEEP_PER_SEC +from .pitch_economy import ( + Economy, + SEEP_PER_SEC, + START_PITCH, + WELL_CAPACITY, + WORKER_LOAD, + SumpWell, + Worker, +) +from .match import Match, MatchPhase, open_match, seed_v0_path, train_t1_melee +from .catalog import FACTION_HOST, FACTION_STAFF, HZ __all__ = [ "Economy", @@ -10,4 +20,12 @@ __all__ = [ "WORKER_LOAD", "WELL_CAPACITY", "SEEP_PER_SEC", + "Match", + "MatchPhase", + "open_match", + "seed_v0_path", + "train_t1_melee", + "FACTION_STAFF", + "FACTION_HOST", + "HZ", ] diff --git a/game/sim/__main__.py b/game/sim/__main__.py new file mode 100644 index 0000000..e88b5db --- /dev/null +++ b/game/sim/__main__.py @@ -0,0 +1,82 @@ +"""CLI: python3 -m game.sim — startable Pitchwell v0 skirmish stub. + +Demo path (GAMEPLAY.md Match loop Open→Seed→Break-lite): + open both factions → Staff seeds Font/Cot/Yard → train Acolytes → attack Host HQ. +""" + +from __future__ import annotations + +import argparse +import json +import sys + +from .catalog import FACTION_HOST, FACTION_STAFF, HZ +from .match import MatchPhase, open_match, seed_v0_path, train_t1_melee + + +def run_demo(ticks: int | None, hz: int, until_end: bool) -> int: + m = open_match(hz=hz) + print("OPEN", json.dumps(m.snapshot(), sort_keys=True)) + notes = seed_v0_path(m, FACTION_STAFF) + print("SEED", " ".join(notes)) + + m.tick_hz(int(50 * hz)) + print("AFTER_SEED", json.dumps(m.snapshot(), sort_keys=True)) + + one = train_t1_melee(m, FACTION_STAFF, n=1) + print("TRAIN_ONE", " ".join(one)) + m.tick_hz(int(10 * hz)) + m.grant_pitch(FACTION_STAFF, 500.0) + train = train_t1_melee(m, FACTION_STAFF, n=4) + print("TRAIN_ARMY", " ".join(train)) + m.tick_hz(int(40 * hz)) + ordered = m.order_attack_hq(FACTION_STAFF) + print(f"ATTACK_HQ ordered={ordered}") + + if until_end: + # Default --ticks must not cap --until-end (was HZ*5 → false early PASS). + safety = ticks if ticks is not None else hz * 600 + for _ in range(safety): + if m.phase == MatchPhase.ENDED: + break + m.tick_hz(1) + else: + m.tick_hz(max(1, ticks if ticks is not None else hz * 5)) + + snap = m.snapshot() + print("END", json.dumps(snap, sort_keys=True)) + if m.phase == MatchPhase.ENDED and m.winner in (FACTION_STAFF, FACTION_HOST): + print(f"PASS: {m.winner} wins via HQ kill") + return 0 + if m.phase == MatchPhase.ENDED and m.winner is None: + print("PASS: draw (both HQs down)") + return 0 + staff = snap["sides"][FACTION_STAFF] + if "pitch_font" in staff["buildings"] and "coven_yard" in staff["buildings"]: + if any(u == "acolyte" for u in staff["units"]): + print("PASS: startable foundation (open+seed+T1) without forced end") + return 0 + print("FAIL: foundation incomplete", file=sys.stderr) + return 1 + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Pitchwell headless match (game.sim)") + p.add_argument("--hz", type=int, default=HZ) + p.add_argument( + "--ticks", + type=int, + default=None, + help="extra ticks after attack (default HZ*5); with --until-end overrides safety cap", + ) + p.add_argument( + "--until-end", + action="store_true", + help="tick until HQ victory or safety cap (default HZ*600)", + ) + args = p.parse_args(argv) + return run_demo(args.ticks, args.hz, args.until_end) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/game/sim/catalog.py b/game/sim/catalog.py new file mode 100644 index 0000000..9ae05bc --- /dev/null +++ b/game/sim/catalog.py @@ -0,0 +1,106 @@ +"""Building / unit catalog ids and costs — docs/FACTIONS.md + docs/GAMEPLAY.md. + +Ids are stable snake_case (FACTIONS.md). Display names stay out of the sim wire. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + + +@dataclass(frozen=True) +class BuildSpec: + id: str + pitch: float + build_s: float + role: str # hq | dropoff | housing | producer | turret | hall + tier: int + pop_bonus: int = 0 # housing only + hp: float = 1000.0 + + +@dataclass(frozen=True) +class UnitSpec: + id: str + pitch: float + build_s: float + role: str # worker | scout | melee | ranged | siege | air + armor: str # Hide | Plate | Ward | Air + hp: float + dps: float + pop: int = 1 + + +# Shared economy numbers (GAMEPLAY.md) +START_PITCH: Final[float] = 500.0 +START_POP_CAP: Final[int] = 10 +POP_CAP_MAX: Final[int] = 80 +WORKER_PITCH: Final[float] = 50.0 +WORKER_BUILD_S: Final[float] = 12.0 +TURRET_PITCH: Final[float] = 150.0 +HZ: Final[int] = 20 + +# Faction harvest modifiers on well seep into the pool (GAMEPLAY.md) +STAFF_HARVEST: Final[float] = 0.85 # −15% +HOST_HARVEST: Final[float] = 1.10 # +10% + +FACTION_STAFF: Final[str] = "crooked_staff" +FACTION_HOST: Final[str] = "rust_host" + +HQ_BY_FACTION: Final[dict[str, str]] = { + FACTION_STAFF: "bent_keep", + FACTION_HOST: "scrap_spire", +} +WORKER_BY_FACTION: Final[dict[str, str]] = { + FACTION_STAFF: "well_walker", + FACTION_HOST: "sump_rig", +} +DROPOFF_BY_FACTION: Final[dict[str, str]] = { + FACTION_STAFF: "pitch_font", + FACTION_HOST: "sump_press", +} +HOUSING_BY_FACTION: Final[dict[str, str]] = { + FACTION_STAFF: "cot_hall", + FACTION_HOST: "frame_rack", +} +T1_HALL_BY_FACTION: Final[dict[str, str]] = { + FACTION_STAFF: "coven_yard", + FACTION_HOST: "bolt_hall", +} +T1_MELEE_BY_FACTION: Final[dict[str, str]] = { + FACTION_STAFF: "acolyte", + FACTION_HOST: "bolt_hand", +} + +BUILDINGS: Final[dict[str, BuildSpec]] = { + "bent_keep": BuildSpec("bent_keep", 0, 0, "hq", 1, hp=5000.0), + "scrap_spire": BuildSpec("scrap_spire", 0, 0, "hq", 1, hp=5000.0), + "pitch_font": BuildSpec("pitch_font", 120, 15, "dropoff", 1, hp=800.0), + "sump_press": BuildSpec("sump_press", 120, 15, "dropoff", 1, hp=800.0), + "cot_hall": BuildSpec("cot_hall", 80, 12, "housing", 1, pop_bonus=8, hp=600.0), + "frame_rack": BuildSpec("frame_rack", 80, 12, "housing", 1, pop_bonus=8, hp=600.0), + "coven_yard": BuildSpec("coven_yard", 180, 20, "producer", 1, hp=1000.0), + "bolt_hall": BuildSpec("bolt_hall", 180, 20, "producer", 1, hp=1000.0), + "ward_post": BuildSpec("ward_post", TURRET_PITCH, 18, "turret", 1, hp=700.0), + "can_turret": BuildSpec("can_turret", TURRET_PITCH, 18, "turret", 1, hp=700.0), + "hex_circle": BuildSpec("hex_circle", 250, 25, "hall", 2, hp=1200.0), + "dynamo": BuildSpec("dynamo", 250, 25, "hall", 2, hp=1200.0), +} + +UNITS: Final[dict[str, UnitSpec]] = { + "well_walker": UnitSpec("well_walker", WORKER_PITCH, WORKER_BUILD_S, "worker", "Hide", 60, 0), + "sump_rig": UnitSpec("sump_rig", WORKER_PITCH, WORKER_BUILD_S, "worker", "Plate", 70, 0), + "acolyte": UnitSpec("acolyte", 75, 8, "ranged", "Hide", 40, 4.0), + "bolt_hand": UnitSpec("bolt_hand", 80, 9, "ranged", "Plate", 55, 3.5), + "crookhound": UnitSpec("crookhound", 100, 10, "scout", "Hide", 45, 5.0), + "rattle_scout": UnitSpec("rattle_scout", 110, 11, "scout", "Plate", 50, 4.5), +} + + +def harvest_mod(faction: str) -> float: + if faction == FACTION_STAFF: + return STAFF_HARVEST + if faction == FACTION_HOST: + return HOST_HARVEST + return 1.0 diff --git a/game/sim/match.py b/game/sim/match.py new file mode 100644 index 0000000..942dbd3 --- /dev/null +++ b/game/sim/match.py @@ -0,0 +1,366 @@ +"""Pitchwell match stub — open / build / tick / HQ victory (GAMEPLAY.md v0 slice). + +Godot-less, CLI-startable. One map, two factions, one well, T1 melee path. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + +from .catalog import ( + BUILDINGS, + DROPOFF_BY_FACTION, + FACTION_HOST, + FACTION_STAFF, + HQ_BY_FACTION, + HZ, + POP_CAP_MAX, + START_PITCH, + START_POP_CAP, + T1_HALL_BY_FACTION, + T1_MELEE_BY_FACTION, + UNITS, + WORKER_BY_FACTION, + BuildSpec, + UnitSpec, + harvest_mod, +) +from .pitch_economy import SEEP_PER_SEC, SumpWell, WELL_CAPACITY + + +class MatchPhase(str, Enum): + OPEN = "open" + RUNNING = "running" + ENDED = "ended" + + +@dataclass +class Building: + spec_id: str + hp: float + max_hp: float + complete: bool = True + remaining_s: float = 0.0 + well_link: Optional[int] = None # index into Match.wells when dropoff + + @property + def role(self) -> str: + return BUILDINGS[self.spec_id].role + + @property + def alive(self) -> bool: + return self.hp > 0.0 + + +@dataclass +class Unit: + spec_id: str + hp: float + max_hp: float + target_hq: bool = False # v0: attack enemy HQ when True + + @property + def spec(self) -> UnitSpec: + return UNITS[self.spec_id] + + @property + def alive(self) -> bool: + return self.hp > 0.0 + + +@dataclass +class QueueItem: + kind: str # building | unit + spec_id: str + remaining_s: float + well_index: Optional[int] = None # dropoff claim target + + +@dataclass +class Side: + faction: str + pitch: float = START_PITCH + pop_cap: int = START_POP_CAP + buildings: list[Building] = field(default_factory=list) + units: list[Unit] = field(default_factory=list) + queue: list[QueueItem] = field(default_factory=list) + claimed_wells: set[int] = field(default_factory=set) + + @property + def hq(self) -> Optional[Building]: + for b in self.buildings: + if b.role == "hq" and b.alive: + return b + return None + + @property + def hq_alive(self) -> bool: + return self.hq is not None + + @property + def pop_used(self) -> int: + return sum(u.spec.pop for u in self.units if u.alive) + + def can_afford(self, cost: float) -> bool: + return self.pitch >= cost + + def has_producer(self) -> bool: + return any(b.complete and b.alive and b.role == "producer" for b in self.buildings) + + def has_building(self, spec_id: str) -> bool: + return any(b.spec_id == spec_id and b.complete and b.alive for b in self.buildings) + + +@dataclass +class Match: + """Two-side skirmish. Win when enemy HQ hp <= 0 (GAMEPLAY.md Modes).""" + + sides: dict[str, Side] + wells: list[SumpWell] = field(default_factory=list) + phase: MatchPhase = MatchPhase.OPEN + winner: Optional[str] = None + tick_i: int = 0 + sim_t: float = 0.0 + hz: int = HZ + + def side(self, faction: str) -> Side: + return self.sides[faction] + + def enemy_of(self, faction: str) -> str: + return FACTION_HOST if faction == FACTION_STAFF else FACTION_STAFF + + def start(self) -> None: + if self.phase == MatchPhase.OPEN: + self.phase = MatchPhase.RUNNING + + def enqueue_building( + self, faction: str, spec_id: str, *, well_index: Optional[int] = None + ) -> str: + """Pay + queue a building. Returns ok or error token.""" + if self.phase == MatchPhase.ENDED: + return "ended" + side = self.side(faction) + if spec_id not in BUILDINGS: + return "unknown_building" + spec: BuildSpec = BUILDINGS[spec_id] + if spec.role == "hq": + return "hq_not_buildable" + if not side.can_afford(spec.pitch): + return "need_pitch" + if len(side.queue) >= 8: + return "queue_full" + if spec.role == "dropoff": + if well_index is None: + return "need_well" + if well_index < 0 or well_index >= len(self.wells): + return "bad_well" + side.pitch -= spec.pitch + side.queue.append( + QueueItem("building", spec_id, spec.build_s, well_index=well_index) + ) + if self.phase == MatchPhase.OPEN: + self.phase = MatchPhase.RUNNING + return "ok" + + def enqueue_unit(self, faction: str, spec_id: str) -> str: + if self.phase == MatchPhase.ENDED: + return "ended" + side = self.side(faction) + if spec_id not in UNITS: + return "unknown_unit" + spec = UNITS[spec_id] + if spec.role != "worker" and not side.has_producer(): + return "need_producer" + if not side.can_afford(spec.pitch): + return "need_pitch" + if side.pop_used + spec.pop > side.pop_cap: + return "need_pop" + if len(side.queue) >= 8: + return "queue_full" + side.pitch -= spec.pitch + side.queue.append(QueueItem("unit", spec_id, spec.build_s)) + if self.phase == MatchPhase.OPEN: + self.phase = MatchPhase.RUNNING + return "ok" + + def grant_pitch(self, faction: str, amount: float) -> None: + """Offline cheat (GAMEPLAY.md: pitch NNNN offline-only).""" + if amount < 0: + raise ValueError("amount must be >= 0") + self.side(faction).pitch += amount + + def order_attack_hq(self, faction: str) -> int: + """All living non-worker units attack enemy HQ. Returns count ordered.""" + n = 0 + for u in self.side(faction).units: + if u.alive and u.spec.role != "worker": + u.target_hq = True + n += 1 + return n + + def _finish_building(self, side: Side, item: QueueItem) -> None: + spec = BUILDINGS[item.spec_id] + b = Building(item.spec_id, spec.hp, spec.hp, complete=True) + if spec.role == "housing": + side.pop_cap = min(POP_CAP_MAX, side.pop_cap + spec.pop_bonus) + if spec.role == "dropoff" and item.well_index is not None: + b.well_link = item.well_index + # Last finished refinery holds the seep (GAMEPLAY.md) + for other in self.sides.values(): + other.claimed_wells.discard(item.well_index) + side.claimed_wells.add(item.well_index) + well = self.wells[item.well_index] + well.claim_via_refinery() + side.buildings.append(b) + + def _finish_unit(self, side: Side, item: QueueItem) -> None: + spec = UNITS[item.spec_id] + side.units.append(Unit(item.spec_id, spec.hp, spec.hp)) + + def _advance_queue(self, side: Side, dt: float) -> None: + """Advance serial build/train queue; carry leftover dt to the next item.""" + left = dt + while side.queue and left > 0.0: + item = side.queue[0] + if item.remaining_s > left: + item.remaining_s -= left + return + left -= item.remaining_s + item.remaining_s = 0.0 + if item.kind == "building": + self._finish_building(side, item) + else: + self._finish_unit(side, item) + side.queue.pop(0) + + def _tick_economy(self, dt: float) -> None: + for idx, well in enumerate(self.wells): + if not well.claimed or well.remaining <= 0.0: + continue + # Owner = faction that holds claim + owner: Optional[str] = None + for fid, side in self.sides.items(): + if idx in side.claimed_wells: + owner = fid + break + if owner is None: + continue + raw = well.tick_seep(dt) + self.sides[owner].pitch += raw * harvest_mod(owner) + + def _tick_combat(self, dt: float) -> None: + for fid, side in self.sides.items(): + enemy = self.sides[self.enemy_of(fid)] + hq = enemy.hq + if hq is None: + continue + for u in side.units: + if not u.alive or not u.target_hq: + continue + dmg = u.spec.dps * dt + hq.hp = max(0.0, hq.hp - dmg) + if hq.hp <= 0.0: + # Remove dead HQ from buildings list conceptually via alive check + break + + def _check_victory(self) -> None: + alive = {fid: side.hq_alive for fid, side in self.sides.items()} + living = [fid for fid, ok in alive.items() if ok] + if len(living) == 1: + self.phase = MatchPhase.ENDED + self.winner = living[0] + elif len(living) == 0: + self.phase = MatchPhase.ENDED + self.winner = None # draw + + def tick(self, dt: float) -> None: + if self.phase == MatchPhase.ENDED: + return + if self.phase == MatchPhase.OPEN: + self.phase = MatchPhase.RUNNING + for side in self.sides.values(): + self._advance_queue(side, dt) + self._tick_economy(dt) + self._tick_combat(dt) + self.tick_i += 1 + self.sim_t += dt + self._check_victory() + + def tick_hz(self, n: int = 1) -> None: + dt = 1.0 / self.hz + for _ in range(n): + if self.phase == MatchPhase.ENDED: + break + self.tick(dt) + + def snapshot(self) -> dict: + out: dict = { + "phase": self.phase.value, + "winner": self.winner, + "tick": self.tick_i, + "t": round(self.sim_t, 3), + "wells": [ + { + "remaining": round(w.remaining, 2), + "claimed": w.claimed, + } + for w in self.wells + ], + "sides": {}, + } + for fid, side in self.sides.items(): + hq = side.hq + out["sides"][fid] = { + "pitch": round(side.pitch, 2), + "pop": f"{side.pop_used}/{side.pop_cap}", + "hq_hp": None if hq is None else round(hq.hp, 1), + "buildings": [b.spec_id for b in side.buildings if b.alive], + "units": [u.spec_id for u in side.units if u.alive], + "queue": [q.spec_id for q in side.queue], + "wells": sorted(side.claimed_wells), + } + return out + + +def open_match(*, hz: int = HZ) -> Match: + """Match open: each side HQ + 4 workers, 500 Pitch, one shared well (GAMEPLAY.md).""" + sides: dict[str, Side] = {} + for faction in (FACTION_STAFF, FACTION_HOST): + hq_id = HQ_BY_FACTION[faction] + hq_spec = BUILDINGS[hq_id] + worker_id = WORKER_BY_FACTION[faction] + w_spec = UNITS[worker_id] + side = Side(faction=faction) + side.buildings.append(Building(hq_id, hq_spec.hp, hq_spec.hp)) + for _ in range(4): + side.units.append(Unit(worker_id, w_spec.hp, w_spec.hp)) + sides[faction] = side + well = SumpWell(remaining=WELL_CAPACITY) + m = Match(sides=sides, wells=[well], hz=hz) + return m + + +def seed_v0_path(m: Match, faction: str) -> list[str]: + """Enqueue Seed slice: dropoff on well 0, housing, T1 producer (GAMEPLAY.md Seed).""" + notes: list[str] = [] + drop = DROPOFF_BY_FACTION[faction] + # housing / hall keyed in catalog helpers — import locally to avoid cycle noise + from .catalog import HOUSING_BY_FACTION + + housing = HOUSING_BY_FACTION[faction] + hall = T1_HALL_BY_FACTION[faction] + for spec_id, kwargs in ( + (drop, {"well_index": 0}), + (housing, {}), + (hall, {}), + ): + r = m.enqueue_building(faction, spec_id, **kwargs) + notes.append(f"{spec_id}:{r}") + return notes + + +def train_t1_melee(m: Match, faction: str, n: int = 3) -> list[str]: + uid = T1_MELEE_BY_FACTION[faction] + return [f"{uid}:{m.enqueue_unit(faction, uid)}" for _ in range(n)] diff --git a/scripts/smoke/headless_smoke.sh b/scripts/smoke/headless_smoke.sh index 8a32d38..758171b 100755 --- a/scripts/smoke/headless_smoke.sh +++ b/scripts/smoke/headless_smoke.sh @@ -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 diff --git a/scripts/smoke/match_smoke.py b/scripts/smoke/match_smoke.py new file mode 100755 index 0000000..190f331 --- /dev/null +++ b/scripts/smoke/match_smoke.py @@ -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()) diff --git a/scripts/smoke/run_all.sh b/scripts/smoke/run_all.sh index edd91ed..4188f42 100755 --- a/scripts/smoke/run_all.sh +++ b/scripts/smoke/run_all.sh @@ -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"