Spaces:
Running on Zero
Running on Zero
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
|