Spaces:
Running on Zero
Running on Zero
| """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" | |
| 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}>" | |
| 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 | |
| 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 | |
| def can_draw_main(self) -> bool: | |
| return bool(self._draw_pile) | |
| 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:] | |