sim: Pitchwell match foundation (open/seed/T1/HQ) + smoke
This commit is contained in:
parent
7358d1ce1a
commit
bd5ade8ec4
7 changed files with 708 additions and 41 deletions
|
|
@ -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__ = [
|
__all__ = [
|
||||||
"Economy",
|
"Economy",
|
||||||
|
|
@ -10,4 +20,12 @@ __all__ = [
|
||||||
"WORKER_LOAD",
|
"WORKER_LOAD",
|
||||||
"WELL_CAPACITY",
|
"WELL_CAPACITY",
|
||||||
"SEEP_PER_SEC",
|
"SEEP_PER_SEC",
|
||||||
|
"Match",
|
||||||
|
"MatchPhase",
|
||||||
|
"open_match",
|
||||||
|
"seed_v0_path",
|
||||||
|
"train_t1_melee",
|
||||||
|
"FACTION_STAFF",
|
||||||
|
"FACTION_HOST",
|
||||||
|
"HZ",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
82
game/sim/__main__.py
Normal file
82
game/sim/__main__.py
Normal file
|
|
@ -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())
|
||||||
106
game/sim/catalog.py
Normal file
106
game/sim/catalog.py
Normal file
|
|
@ -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
|
||||||
366
game/sim/match.py
Normal file
366
game/sim/match.py
Normal file
|
|
@ -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)]
|
||||||
|
|
@ -1,16 +1,9 @@
|
||||||
#!/usr/bin/env bash
|
#!/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
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
N_TICKS="${1:-40}"
|
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"
|
STUB="${ROOT}/assets/cinematics/first-contact/out/first-contact-stub.mp4"
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
|
|
@ -18,7 +11,6 @@ fail() {
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Optional cinematic probe (GAMEPLAY.md: briefings ≥1280×720)
|
|
||||||
if [[ -f "${STUB}" ]]; then
|
if [[ -f "${STUB}" ]]; then
|
||||||
if command -v ffprobe >/dev/null 2>&1; 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)"
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Pure-Python 20 Hz tick loop: match open — HQ exists, claimed well seeps Pitch
|
|
||||||
OUT="$(
|
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
|
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"])
|
n = int(os.environ["N_TICKS"])
|
||||||
hz = int(os.environ["HZ"])
|
m = open_match()
|
||||||
seep = float(os.environ["SEEP_PER_S"])
|
staff = m.side(FACTION_STAFF)
|
||||||
pitch = float(os.environ["START_PITCH"])
|
# claim well via finished font so seep applies (match open alone has neutral well)
|
||||||
dt = float(os.environ["DT"])
|
r = m.enqueue_building(FACTION_STAFF, "pitch_font", well_index=0)
|
||||||
|
if r != "ok":
|
||||||
hq_alive = True
|
print("enqueue font", r, file=sys.stderr)
|
||||||
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)
|
|
||||||
sys.exit(1)
|
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)
|
print("HQ missing", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
print(f"ticks={n} hz={HZ} pitch={staff.pitch:.4f} hq=1 gain={got:.4f}")
|
||||||
print(f"ticks={n} hz={hz} pitch={pitch:.4f} hq=1 seep={seep}/s")
|
|
||||||
PY
|
PY
|
||||||
)" || fail "sim ${OUT}"
|
)" || fail "sim"
|
||||||
|
|
||||||
echo "SMOKE PASS headless ${OUT}"
|
echo "SMOKE PASS headless ${OUT}"
|
||||||
exit 0
|
exit 0
|
||||||
|
|
|
||||||
103
scripts/smoke/match_smoke.py
Executable file
103
scripts/smoke/match_smoke.py
Executable 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())
|
||||||
|
|
@ -13,6 +13,7 @@ run() {
|
||||||
[[ -x scripts/smoke/headless_smoke.sh ]] && run scripts/smoke/headless_smoke.sh
|
[[ -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
|
[[ -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/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
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
echo "RUN_ALL PASS"
|
echo "RUN_ALL PASS"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue