File size: 4,500 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
"""The greedy floor bot: the engine's reference player.

Lives inside the shipped package (not balance/) because the encounter
author sim-gates its compositions at runtime against this bot. balance/
imports from here for the offline reports — one bot, one floor.

Deliberately simple: block the scariest lane, never trade away tempo,
take a bit when the board is starving. Humans play better; every number
this bot produces is a floor, not a target.
"""

from __future__ import annotations

from .cards import Card, CostType, mem_value
from .combat import LANES, CombatState, Phase


def choose_sacrifices(
    state: CombatState, cost: int, keep_lane: int | None = None, max_power: int = 99
) -> tuple[int, ...] | None:
    """Cheapest legal sacrifice set, or None. Avoids over-sacrifice and
    never trades away as much power as the new card brings (tempo rule)."""
    lanes = sorted(
        (
            i for i in range(LANES)
            if state.player_row[i] is not None
            and i != keep_lane
            and state.player_row[i].power < max_power
        ),
        key=lambda i: (state.player_row[i].power, state.player_row[i].health),
    )
    chosen: list[int] = []
    paid = 0
    for lane in lanes:
        if paid >= cost:
            break
        chosen.append(lane)
        paid += mem_value(state.player_row[lane])
    if paid < cost:
        return None
    # Drop victims made unnecessary by a later, bigger one.
    for lane in list(chosen):
        value = mem_value(state.player_row[lane])
        if paid - value >= cost:
            chosen.remove(lane)
            paid -= value
    if sum(state.player_row[i].power for i in chosen) >= max_power:
        return None  # the trade loses tempo
    return tuple(chosen)


def choose_lane(state: CombatState, card: Card) -> int | None:
    """Block the scariest attacker if we can take a hit, else hit air."""
    free = [i for i in range(LANES) if state.player_row[i] is None]
    if not free:
        return None
    threats = sorted(
        (i for i in free if state.foe_row[i] is not None and state.foe_row[i].power > 0),
        key=lambda i: -state.foe_row[i].power,
    )
    if threats and card.health >= 2:
        return threats[0]
    open_lanes = [i for i in free if state.foe_row[i] is None]
    return (open_lanes or free)[0]


def wants_fodder(state: CombatState) -> bool:
    """Draw a bit when there is nothing on board to pay mem costs with."""
    board = sum(1 for c in state.player_row if c is not None)
    free_in_hand = sum(1 for c in state.hand if c.spec.cost.type is CostType.FREE)
    return board + free_in_hand < 2


def bot_turn(state: CombatState) -> None:
    if state.phase is Phase.DRAW:
        if state.can_draw_side and (wants_fodder(state) or not state.can_draw_main):
            state.draw("side")
        else:
            state.draw("main")
    # Play the strongest affordable cards while board space remains.
    # Hard cap on plays per turn: self_replicating cards can otherwise feed
    # the bot an infinite sacrifice->replay loop (ask us how we know).
    progress = True
    plays = 0
    while progress and plays < 8 and state.phase is Phase.MAIN:
        progress = False
        order = sorted(
            range(len(state.hand)), key=lambda i: -state.hand[i].spec.power
        )
        for idx in order:
            card = state.hand[idx].spec
            lane = choose_lane(state, card)
            if lane is None:
                break
            if card.cost.type is CostType.FREE:
                sacrifices: tuple[int, ...] | None = ()
            elif card.cost.type is CostType.DUMPS:
                sacrifices = () if state.dumps >= card.cost.amount else None
            else:
                sacrifices = choose_sacrifices(
                    state, card.cost.amount, keep_lane=lane, max_power=max(card.power, 1)
                )
            if sacrifices is None:
                continue
            try:
                state.play(idx, lane, sacrifices=sacrifices)
                progress = True
                plays += 1
                break
            except Exception:
                continue
    state.ring_bell()


def simulate(deck: list[Card], side: list[Card], script, seed: int, max_turns: int = 30) -> CombatState:
    state = CombatState(main_deck=deck, side_deck=side, script=script, seed=seed)
    for _ in range(max_turns):
        if state.phase is Phase.OVER:
            break
        bot_turn(state)
    return state