Scrypt / scrypt /engine /bots.py
IMJONEZZ's picture
SCRYPT: initial commit — game, sandbox, Warden, Space web layer
9fca766
Raw
History Blame Contribute Delete
4.5 kB
"""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