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