"""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