Scrypt / scrypt /engine /combat.py
IMJONEZZ's picture
SCRYPT: initial commit — game, sandbox, Warden, Space web layer
9fca766
Raw
History Blame Contribute Delete
16.2 kB
"""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:]