"""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:]