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