Spaces:
Running on Zero
Running on Zero
File size: 16,194 Bytes
9fca766 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 | """Deterministic combat resolver: the balance meter, four lanes, mem and dumps.
Combat structure (Inscryption Act I-style mechanics, our expression):
- The board has LANES columns. The player owns one row; the foe owns a front
row (which attacks) and a queue row (which advances into the front row).
- The player wins by tipping the balance +5 in their favor, loses at -5.
Damage past +5 is overkill and becomes cycles (currency).
- Mem costs are paid by sacrificing the player's own running processes at
play time. Core dumps accumulate when the player's cards die.
- The foe never pays costs; its plays come from an encounter script.
The resolver is pure: no IO, no clocks, RNG injected via seed. Every state
change appends an Event so the UI can animate and tests can golden-replay.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from .cards import Card, CardInstance, CostType, mem_value
LANES = 4
WIN_AT = 5
class Phase(Enum):
DRAW = "draw"
MAIN = "main"
OVER = "over"
class Result(Enum):
UNDECIDED = "undecided"
PLAYER_WIN = "player_win"
PLAYER_LOSS = "player_loss"
@dataclass(frozen=True)
class Event:
kind: str
data: dict
def __repr__(self) -> str: # compact, replay-log friendly
inner = " ".join(f"{k}={v}" for k, v in self.data.items())
return f"<{self.kind} {inner}>"
@dataclass(frozen=True)
class ScriptedPlay:
"""One foe card entering the queue: (lane, card spec)."""
lane: int
card: Card
# An encounter script: script[turn_index] -> plays made after the foe's attack
# on that turn. Turn 0 plays are placed before the player's first turn.
EncounterScript = list[list[ScriptedPlay]]
class IllegalMove(Exception):
pass
@dataclass
class CombatState:
main_deck: list[Card]
side_deck: list[Card] # the bit pile: free fodder, drawn instead of a real card
script: EncounterScript
seed: int = 0
phase: Phase = Phase.DRAW
result: Result = Result.UNDECIDED
turn: int = 0
scale: int = 0 # positive favors the player
overkill_cycles: int = 0
dumps: int = 0
hand: list[CardInstance] = field(default_factory=list)
player_row: list[Optional[CardInstance]] = field(default_factory=lambda: [None] * LANES)
foe_row: list[Optional[CardInstance]] = field(default_factory=lambda: [None] * LANES)
foe_queue: list[Optional[CardInstance]] = field(default_factory=lambda: [None] * LANES)
events: list[Event] = field(default_factory=list)
def __post_init__(self) -> None:
import random
# Per-combat uid stream so event logs are reproducible per seed,
# independent of the global CardInstance counter. A plain int so
# the whole state stays deepcopy-able (preview_bell's ghost).
self._next_uid = 0
self._rng = random.Random(self.seed)
self._draw_pile = list(self.main_deck)
self._rng.shuffle(self._draw_pile)
self._side_pile = list(self.side_deck)
# Opening: foe's turn-0 queue placement, then the player draws an
# opening hand of one bit + three from the main deck.
self._foe_script_plays(0)
self._advance_queue()
self._foe_script_plays(1)
for _ in range(3):
if self._draw_pile:
self._take(self._draw_pile)
if self._side_pile:
self._take(self._side_pile)
self.phase = Phase.DRAW
# ------------------------------------------------------------- helpers
def _emit(self, kind: str, **data) -> None:
self.events.append(Event(kind, data))
def _spawn(self, spec: Card) -> CardInstance:
card = CardInstance(spec=spec)
self._next_uid += 1
card.uid = self._next_uid
return card
def _take(self, pile: list[Card]) -> CardInstance:
card = self._spawn(pile.pop(0))
self.hand.append(card)
self._emit("drew", card=card.spec.id, uid=card.uid)
return card
def _check_scale(self) -> None:
if self.result is not Result.UNDECIDED:
return
if self.scale >= WIN_AT:
self.overkill_cycles += self.scale - WIN_AT
self.scale = WIN_AT
self.result = Result.PLAYER_WIN
self.phase = Phase.OVER
self._emit("combat_over", result=self.result.value, cycles=self.overkill_cycles)
elif self.scale <= -WIN_AT:
self.scale = -WIN_AT
self.result = Result.PLAYER_LOSS
self.phase = Phase.OVER
self._emit("combat_over", result=self.result.value, cycles=0)
def _die(self, row: list[Optional[CardInstance]], lane: int, *, player_owned: bool) -> None:
card = row[lane]
assert card is not None
row[lane] = None
if player_owned:
self.dumps += 1
self._emit("dumps", total=self.dumps)
self._emit("died", card=card.spec.id, uid=card.uid, lane=lane, player=player_owned)
def _hit_card(
self,
row: list[Optional[CardInstance]],
lane: int,
amount: int,
*,
player_owned: bool,
null_pointer: bool = False,
) -> None:
card = row[lane]
assert card is not None
card.health = 0 if (null_pointer and amount > 0) else card.health - amount
self._emit("damaged", card=card.spec.id, uid=card.uid, lane=lane, amount=amount)
if not card.alive:
self._die(row, lane, player_owned=player_owned)
# ------------------------------------------------------- player actions
def draw(self, source: str) -> CardInstance:
"""source: 'main' or 'side'."""
if self.phase is not Phase.DRAW:
raise IllegalMove("not in draw phase")
pile = {"main": self._draw_pile, "side": self._side_pile}.get(source)
if pile is None:
raise IllegalMove(f"unknown draw source {source!r}")
if not pile:
raise IllegalMove(f"{source} pile is empty")
card = self._take(pile)
self.phase = Phase.MAIN
return card
@property
def can_draw_main(self) -> bool:
return bool(self._draw_pile)
@property
def can_draw_side(self) -> bool:
return bool(self._side_pile)
def skip_draw(self) -> None:
"""Both piles empty: the draw phase is skipped, not stalled."""
if self.phase is not Phase.DRAW:
raise IllegalMove("not in draw phase")
if self._draw_pile or self._side_pile:
raise IllegalMove("must draw while a pile remains")
self.phase = Phase.MAIN
def play(self, hand_index: int, lane: int, sacrifices: tuple[int, ...] = ()) -> None:
"""Play hand[hand_index] into lane, paying its cost.
Mem costs name the lanes of the player's own cards to sacrifice.
Sacrifices resolve first, so a card may be played into a lane it
just emptied.
"""
if self.phase is not Phase.MAIN:
raise IllegalMove("not in main phase")
if not 0 <= lane < LANES:
raise IllegalMove(f"lane {lane} out of range")
if not 0 <= hand_index < len(self.hand):
raise IllegalMove(f"no card at hand index {hand_index}")
card = self.hand[hand_index]
cost = card.spec.cost
if cost.type is CostType.DUMPS:
if sacrifices:
raise IllegalMove("dump-cost cards take no sacrifices")
if self.dumps < cost.amount:
raise IllegalMove(f"need {cost.amount} dumps, have {self.dumps}")
elif cost.type is CostType.MEM:
victims = []
for s in sacrifices:
victim = self.player_row[s] if 0 <= s < LANES else None
if victim is None:
raise IllegalMove(f"no card to sacrifice in lane {s}")
if victim in victims:
raise IllegalMove(f"lane {s} named twice")
victims.append(victim)
paid = sum(mem_value(v) for v in victims)
if paid < cost.amount:
raise IllegalMove(f"need {cost.amount} mem, offered {paid}")
# Reject obvious overpayment: dropping any victim must underpay.
if victims and all(
paid - mem_value(v) >= cost.amount for v in victims
):
raise IllegalMove("over-sacrifice: a named victim is unnecessary")
elif sacrifices:
raise IllegalMove("free cards take no sacrifices")
# The target lane must be free once sacrifices resolve (sacrificing
# the occupant of the target lane is legal and common).
occupant = self.player_row[lane]
if occupant is not None and (
lane not in sacrifices or occupant.has("auto_restart")
):
raise IllegalMove(f"lane {lane} is occupied")
# Costs are validated; resolve sacrifices before placement.
if cost.type is CostType.MEM:
for s in sacrifices:
victim = self.player_row[s]
assert victim is not None
self._emit("sacrificed", card=victim.spec.id, uid=victim.uid, lane=s)
if victim.has("auto_restart"):
continue # the watchdog restarts itself
self._die(self.player_row, s, player_owned=True)
elif cost.type is CostType.DUMPS:
self.dumps -= cost.amount
self._emit("dumps", total=self.dumps)
self.hand.pop(hand_index)
self.player_row[lane] = card
self._emit("played", card=card.spec.id, uid=card.uid, lane=lane, player=True)
if card.has("self_replicating"):
copy = self._spawn(card.spec)
self.hand.append(copy)
self._emit("self_replicating", card=copy.spec.id, uid=copy.uid)
def ring_bell(self) -> None:
"""End the player's turn: player attacks, foe attacks, queue advances."""
if self.phase is not Phase.MAIN:
raise IllegalMove("not in main phase")
self._attack_row(attacker_player=True)
if self.phase is Phase.OVER:
return
# End-of-turn upkeep for the player's board.
for card in self.player_row:
if card is not None and card.has("scavenger_loop"):
self.dumps += 1
self._emit("dumps", total=self.dumps)
self._attack_row(attacker_player=False)
if self.phase is Phase.OVER:
return
self.turn += 1
self._advance_queue()
self._foe_script_plays(self.turn + 1)
self.phase = Phase.DRAW
if not self._draw_pile and not self._side_pile:
self.phase = Phase.MAIN
self._emit("turn", n=self.turn)
# ----------------------------------------------------- warden tampering
# The only sanctioned mutation points for the director. Bounded here as
# well as by the director's budget — defense in depth.
def throttle_player_card(self, lane: int) -> str:
"""-1 power to the player's card in lane (never below 0)."""
card = self.player_row[lane]
if card is None:
raise IllegalMove(f"no player card in lane {lane}")
card.power_bonus -= 1
self._emit("throttled", card=card.spec.id, uid=card.uid, lane=lane)
return f"{card.spec.id} throttled to {card.power} power"
def reinforce_queue(self, lane: int, card_spec: Card) -> str:
"""Drop one extra foe card into an empty queue lane."""
if self.foe_queue[lane] is not None:
raise IllegalMove(f"queue lane {lane} is occupied")
card = self._spawn(card_spec)
self.foe_queue[lane] = card
self._emit("reinforced", card=card.spec.id, uid=card.uid, lane=lane)
return f"{card.spec.id} queued in lane {lane}"
def withdraw_queue(self, lane: int) -> str:
"""Secret mercy: a queued foe card quietly never arrives."""
card = self.foe_queue[lane]
if card is None:
raise IllegalMove(f"no queued card in lane {lane}")
self.foe_queue[lane] = None
self._emit("withdrawn", card=card.spec.id, uid=card.uid, lane=lane)
return f"{card.spec.id} withdrawn from lane {lane}"
# -------------------------------------------------------- foe machinery
def _foe_script_plays(self, turn: int) -> None:
if turn >= len(self.script):
return
for play in self.script[turn]:
if self.foe_queue[play.lane] is None:
card = self._spawn(play.card)
self.foe_queue[play.lane] = card
self._emit("queued", card=card.spec.id, uid=card.uid, lane=play.lane)
def _advance_queue(self) -> None:
for lane in range(LANES):
if self.foe_row[lane] is None and self.foe_queue[lane] is not None:
card = self.foe_queue[lane]
self.foe_queue[lane] = None
self.foe_row[lane] = card
self._emit("advanced", card=card.spec.id, uid=card.uid, lane=lane)
# ------------------------------------------------------------ attacking
def _attack_row(self, *, attacker_player: bool) -> None:
attackers = self.player_row if attacker_player else self.foe_row
defenders = self.foe_row if attacker_player else self.player_row
for lane in range(LANES):
card = attackers[lane]
if card is None or card.power == 0:
continue
targets = (
[lane - 1, lane + 1] if card.has("forked") else [lane]
)
for t in targets:
if not 0 <= t < LANES:
continue
self._strike(card, t, defenders, attacker_player=attacker_player)
if self.phase is Phase.OVER:
return
if not card.alive: # killed by a honeypot defender mid-swing
break
def _strike(
self,
attacker: CardInstance,
lane: int,
defenders: list[Optional[CardInstance]],
*,
attacker_player: bool,
) -> None:
blocker = defenders[lane]
tunneling = attacker.has("tunneling")
if tunneling and blocker is not None and not blocker.has("packet_filter"):
blocker = None # flies over
if blocker is None:
dealt = attacker.power
self.scale += dealt if attacker_player else -dealt
self._emit(
"face_damage", amount=dealt, by=attacker.spec.id, player=attacker_player,
lane=lane, scale=self.scale,
)
self._check_scale()
return
self._emit(
"strike", by=attacker.spec.id, at=blocker.spec.id, lane=lane,
player=attacker_player, amount=attacker.power,
)
self._hit_card(
defenders,
lane,
attacker.power,
player_owned=not attacker_player,
null_pointer=attacker.has("null_pointer"),
)
if blocker.has("honeypot") and attacker.alive:
attacker.health -= 1
self._emit("honeypot_recoil", card=attacker.spec.id, uid=attacker.uid)
if not attacker.alive:
row = self.player_row if attacker_player else self.foe_row
if attacker in row:
self._die(row, row.index(attacker), player_owned=attacker_player)
def preview_bell(state: CombatState) -> list[Event]:
"""What ring_bell() would do right now, exactly — played out on a ghost
copy so the real table never moves. The same resolver produces the
preview and the resolution; the two cannot drift apart.
Phase-agnostic: a player still deciding what to draw deserves to see
what the bell already holds. Returns [] only when there is no table.
"""
import copy
if state.phase is Phase.OVER:
return []
ghost = copy.deepcopy(state)
ghost.phase = Phase.MAIN
before = len(ghost.events)
ghost.ring_bell()
return ghost.events[before:]
|