LovecaSim / engine /game /fast_logic.py
trioskosmos's picture
Upload folder using huggingface_hub
bb3fbf9 verified
import warnings
import numpy as np
from numba import njit, prange
# Suppress Numba cache warnings (harmless in testing environments)
warnings.filterwarnings("ignore", message=".*'cache' is set for njit and is ignored.*")
# =============================================================================
# HYPER-OPTIMIZED VM CORE (Production Version)
# =============================================================================
# ContextIndex Mappings (Raw Ints)
CV = 20
AT = 22
TS = 12
TI = 13
SZ = 7
CH = 15
SID = 5
# Backward compatibility aliases
CTX_VALUE = CV
CTX_ATTR = AT
CTX_TARGET_SLOT = TS
CTX_TARGET_PLAYER_ID = TI
CTX_SOURCE_ZONE_IDX = SZ
CTX_CHOICE_INDEX = CH
# GlobalContext Mappings
SC = 0
OS = 1
TR = 2
HD = 3
DI = 4
EN = 5
DK = 6
OT = 7
OT = 7
PH = 8
LS = 110 # Live Score Bonus (Temporary, moved to 110 to avoid conflict with OD)
EH = 111 # Excess Hearts (Current Live Performance)
PREV_CID_IDX = 60 # Previous Card ID for Baton Pass (Index 60)
# Unique ID (UID) System
BASE_ID_MASK = 0xFFFFF
@njit(inline="always")
def get_base_id(uid: int) -> int:
"""Extract the base card definition ID (0-1999) from a UID."""
return uid & BASE_ID_MASK
# Opcodes
O_DRAW = 10
O_BLADES = 11
O_HEARTS = 12
O_REDUCE_COST = 13
O_LOOK_DECK = 14
O_RECOV_L = 15
O_BOOST = 16
O_RECOV_M = 17
O_BUFF = 18
O_MOVE_MEMBER = 20
O_SWAP_CARDS = 21
O_SEARCH_DECK = 22
O_CHARGE = 23
O_ORDER_DECK = 28
O_SET_BLADES = 24
O_SELECT_MODE = 30
O_TAP_O = 32
O_PLACE_UNDER = 33
O_LOOK_AND_CHOOSE = 41
O_ACTIVATE_MEMBER = 43
O_ADD_H = 44
O_REPLACE_EFFECT = 46
O_TRIGGER_REMOTE = 47
O_TRANSFORM_COLOR = 39
O_TAP_M = 53
O_REDUCE_HEART_REQ = 48
O_REVEAL_CARDS = 26
O_MOVE_TO_DISCARD = 58
O_RETURN = 1
O_JUMP = 2
O_JUMP_F = 3
O_REVEAL_HAND_ALL = 6 # Cost Type (Internal reminder)
# Conditions
C_TR1 = 200
C_CLR = 202
C_STG = 203
C_HND = 204
C_CTR = 206
C_LLD = 207
C_GRP = 208
C_OPH = 210
C_ENR = 213
C_OPH = 210
C_ENR = 213
C_CMP = 220
C_BLD = 224
C_HRT = 223
C_HAS_CHOICE = 221
C_OPPONENT_CHOICE = 222
C_BATON = 231 # Baton Pass condition (ID 231)
# Conditions
C_TR1 = 200
C_CLR = 202
C_STG = 203
C_HND = 204
C_CTR = 206
C_LLD = 207
C_GRP = 208
C_OPH = 210
C_ENR = 213
C_OPH = 210
C_ENR = 213
C_CMP = 220
C_BLD = 224
C_HRT = 223
C_HAS_CHOICE = 221
C_OPPONENT_CHOICE = 222
@njit(nopython=True, cache=True, fastmath=True)
def resolve_bytecode(
bytecode,
flat_ctx,
global_ctx,
player_id,
p_hand,
p_deck,
p_stage,
p_energy_vec,
p_energy_count,
p_cont_vec,
out_cptr, # Modified: Pass by ref array (size 1)
p_tapped,
p_live,
opp_tapped,
p_trash, # Added
b_map,
b_idx,
out_bonus, # Modified: Pass by ref array (size 1)
card_stats, # Added: NumPy array for card properties
opp_stage, # Added
opp_tapped_count=None, # Optional tracking
):
# Manual Stack Unrolling (Depth 4) - Eliminates allocations in hot loop
stack_ptr = 0
bc0 = bytecode
ip0 = 0
bc1 = bytecode
ip1 = 0
bc2 = bytecode
ip2 = 0
bc3 = bytecode
ip3 = 0
cptr = out_cptr[0]
cond = True
safety_counter = 0
while stack_ptr >= 0 and safety_counter < 1000:
safety_counter += 1
# Current Frame Selection (Type-stable for Numba)
if stack_ptr == 0:
cur_bc = bc0
ip = ip0
elif stack_ptr == 1:
cur_bc = bc1
ip = ip1
elif stack_ptr == 2:
cur_bc = bc2
ip = ip2
else:
cur_bc = bc3
ip = ip3
blen = cur_bc.shape[0]
if ip >= blen:
stack_ptr -= 1
continue
op = cur_bc[ip, 0]
v = cur_bc[ip, 1]
a = cur_bc[ip, 2]
s = cur_bc[ip, 3]
# Advance IP logic: Pre-increment local copy
next_ip = ip + 1
if stack_ptr == 0:
ip0 = next_ip
elif stack_ptr == 1:
ip1 = next_ip
elif stack_ptr == 2:
ip2 = next_ip
else:
ip3 = next_ip
if op == 0:
continue
if op == O_RETURN:
stack_ptr -= 1
continue
# Negation Flag Handling
is_negated = False
if op >= 1000:
is_negated = True
op -= 1000
# Dynamic Target Handling (MEMBER_SELECT)
if s == 10:
s = int(flat_ctx[TS])
if op == O_JUMP:
new_ip = ip + v
if stack_ptr == 0:
ip0 = new_ip if 0 <= new_ip < blen else blen
elif stack_ptr == 1:
ip1 = new_ip if 0 <= new_ip < blen else blen
elif stack_ptr == 2:
ip2 = new_ip if 0 <= new_ip < blen else blen
else:
ip3 = new_ip if 0 <= new_ip < blen else blen
continue
if op == O_JUMP_F:
if not cond:
new_ip = ip + v
if stack_ptr == 0:
ip0 = new_ip if 0 <= new_ip < blen else blen
elif stack_ptr == 1:
ip1 = new_ip if 0 <= new_ip < blen else blen
elif stack_ptr == 2:
ip2 = new_ip if 0 <= new_ip < blen else blen
else:
ip3 = new_ip if 0 <= new_ip < blen else blen
continue
continue
if op == O_SELECT_MODE:
choice = int(flat_ctx[CH])
jumped = False
if 0 <= choice < v:
jump_ip = ip + 1 + choice
if jump_ip < blen:
offset = cur_bc[jump_ip, 1]
new_ip = jump_ip + offset
if 0 <= new_ip < blen:
if stack_ptr == 0:
ip0 = new_ip
elif stack_ptr == 1:
ip1 = new_ip
elif stack_ptr == 2:
ip2 = new_ip
else:
ip3 = new_ip
jumped = True
if not jumped:
new_ip = ip + (v + 1)
if stack_ptr == 0:
ip0 = new_ip
elif stack_ptr == 1:
ip1 = new_ip
elif stack_ptr == 2:
ip2 = new_ip
else:
ip3 = new_ip
continue
if op >= 200:
if op == C_TR1:
cond = global_ctx[TR] == 1
elif op == C_STG:
ct = 0
for i in range(3):
if p_stage[i] != -1:
ct += 1
cond = ct >= v
elif op == C_HND:
cond = global_ctx[HD] >= v
elif op == C_LLD:
cond = global_ctx[SC] > global_ctx[OS]
elif op == C_CLR:
if 0 <= a <= 5:
cond = global_ctx[10 + a] > 0
else:
cond = False
elif op == C_GRP:
# C_GRP: Count cards of a group on stage or live zone
# v = min count, a = group ID, s = zone (0=stage, 1=live)
target_group = a
grp_count = 0
if s == 0: # Stage
for slot_k in range(3):
cid = p_stage[slot_k]
if cid >= 0:
bid = get_base_id(cid)
if bid < card_stats.shape[0]:
# Groups are stored at index 6 (primary group)
if card_stats[bid, 6] == target_group:
grp_count += 1
elif s == 1: # Live Zone
for lz_k in range(p_live.shape[0]):
cid = p_live[lz_k]
if cid >= 0:
bid = get_base_id(cid)
if bid < card_stats.shape[0]:
if card_stats[bid, 6] == target_group:
grp_count += 1
cond = grp_count >= v
elif op == C_BLD:
# C_BLD: Check if total blades on stage meets requirement
# Rule 9.9: Tapped (Resting) members do not contribute.
total_blades_check = 0
for slot_k in range(3):
cid = p_stage[slot_k]
if cid >= 0 and p_tapped[slot_k] == 0:
bid = get_base_id(cid)
if bid < card_stats.shape[0]:
total_blades_check += card_stats[bid, 1] # Blades at index 1
# Decode slot/comparison
real_slot = s & 0x0F
comp = (s >> 4) & 0x0F
if comp == 0:
cond = total_blades_check >= v
elif comp == 1:
cond = total_blades_check <= v
elif comp == 2:
cond = total_blades_check > v
elif comp == 3:
cond = total_blades_check < v
else:
cond = total_blades_check == v
elif op == C_HRT:
# C_HRT: Check if total hearts of color `a` on stage meets requirement
total_hearts_check = 0
# Decode slot/comparison
real_slot = s & 0x0F
comp = (s >> 4) & 0x0F
if real_slot == 2: # Live Result / Excess Hearts
total_hearts_check = global_ctx[EH]
else:
for slot_k in range(3):
cid = p_stage[slot_k]
# Rule 9.9: Tapped members do not contribute hearts either.
if cid >= 0 and p_tapped[slot_k] == 0:
bid = get_base_id(cid)
if bid < card_stats.shape[0]:
if 0 <= a <= 6:
total_hearts_check += card_stats[bid, 12 + a] # Hearts at 12-18
else:
# Sum all hearts
for h_k in range(7):
total_hearts_check += card_stats[bid, 12 + h_k]
if comp == 0:
cond = total_hearts_check >= v
elif comp == 1:
cond = total_hearts_check <= v
elif comp == 2:
cond = total_hearts_check > v
elif comp == 3:
cond = total_hearts_check < v
else:
cond = total_hearts_check == v
elif op == C_ENR:
cond = global_ctx[EN] >= v
elif op == C_CTR:
cond = flat_ctx[SZ] == 1
elif op == C_CMP:
if v > 0:
cond = global_ctx[SC] >= v
else:
cond = global_ctx[SC] > global_ctx[OS]
elif op == C_OPH:
ct = global_ctx[OT]
if v > 0:
cond = ct >= v
else:
cond = ct > 0
elif op == 230: # C_LIVE_ZONE
# Placeholder: correctly count cards in successful live zone if needed
cond = True
elif op == 212: # C_MODAL_ANSWER
cond = global_ctx[CH] == v
elif op == C_HAS_CHOICE:
cond = True
elif op == C_OPPONENT_CHOICE:
# Check if opponent tapped ANY card this turn (safe approximation)
# We check the passed `opp_tapped` array.
cond = False
for k in range(3):
if opp_tapped[k] > 0:
cond = True
break
elif op == C_BATON:
# Baton Pass condition: Check if the previous card's Character ID matches target
prev_cid = global_ctx[PREV_CID_IDX]
if prev_cid >= 0:
prev_bid = get_base_id(prev_cid)
if prev_bid < card_stats.shape[0]:
# Check if the previous card's Character ID (stored in stats index 19) matches the target
cond = card_stats[prev_bid, 19] == v
else:
cond = False
else:
cond = False
if is_negated:
cond = not cond
if not cond:
stack_ptr -= 1
continue
else:
if cond:
if op == O_DRAW:
drawn = 0
for d_idx in range(60):
if p_deck[d_idx] > 0:
card_id = p_deck[d_idx]
p_deck[d_idx] = 0
global_ctx[DK] -= 1
for h_idx in range(60):
if p_hand[h_idx] == 0:
p_hand[h_idx] = card_id
global_ctx[HD] += 1
break
drawn += 1
if drawn >= v:
break
elif op == O_TRANSFORM_COLOR:
# Rule 11.12: Transformation effects.
# Add to continuous effects buffer.
# Entry: [type, value, attr, slot, p_id, duration, ...]
# and TRANSFORM_COLOR usually lasts until LIVE_END (duration=2 in this implementation?)
# attr is target_color index
if (
cptr < p_cont_vec.shape[0]
): # Changed from p_ptr[0] to cptr, and p_cont_vec.shape[1] to p_cont_vec.shape[0]
idx = cptr
p_cont_vec[idx, 0] = O_TRANSFORM_COLOR
p_cont_vec[idx, 1] = v
p_cont_vec[idx, 2] = a
p_cont_vec[idx, 3] = s
p_cont_vec[idx, 4] = player_id
p_cont_vec[idx, 5] = 2 # Duration: LIVE_END
cptr += 1 # Changed from p_ptr[0] += 1 to cptr += 1
elif op == O_CHARGE:
charged = 0
for d_idx in range(60):
if p_deck[d_idx] > 0:
card_id = p_deck[d_idx]
p_deck[d_idx] = 0
global_ctx[DK] -= 1
if 0 <= s < 3:
for e_idx in range(32):
if p_energy_vec[s, e_idx] == 0:
p_energy_vec[s, e_idx] = card_id
p_energy_count[s] += 1
# Rule 10.6: Energy under members does NOT count as global energy
# global_ctx[EN] += 1 <-- REMOVED
break
charged += 1
if charged >= v:
break
elif op == O_BLADES:
if s >= 0 and cptr < 32:
p_cont_vec[cptr, 0] = 1
p_cont_vec[cptr, 1] = v
p_cont_vec[cptr, 2] = 4
p_cont_vec[cptr, 3] = s
p_cont_vec[cptr, 8] = int(flat_ctx[SID])
p_cont_vec[cptr, 9] = 1
cptr += 1
elif op == O_HEARTS:
if cptr < 32:
p_cont_vec[cptr, 0] = 2
p_cont_vec[cptr, 1] = v
p_cont_vec[cptr, 5] = a
p_cont_vec[cptr, 8] = int(flat_ctx[SID])
p_cont_vec[cptr, 9] = 1
cptr += 1
if 10 + a < 128:
global_ctx[10 + a] += v
elif op == O_REDUCE_COST:
if cptr < 32:
p_cont_vec[cptr, 0] = 3
p_cont_vec[cptr, 1] = v
p_cont_vec[cptr, 2] = s
p_cont_vec[cptr, 8] = int(flat_ctx[SID])
p_cont_vec[cptr, 9] = 1
cptr += 1
elif op == O_REDUCE_HEART_REQ:
if cptr < 32:
p_cont_vec[cptr, 0] = 48
p_cont_vec[cptr, 1] = v
p_cont_vec[cptr, 2] = s
p_cont_vec[cptr, 8] = int(flat_ctx[SID])
p_cont_vec[cptr, 9] = 1
cptr += 1
elif op == O_SET_BLADES:
if s >= 0 and cptr < 32:
p_cont_vec[cptr, 0] = 24
p_cont_vec[cptr, 1] = v
p_cont_vec[cptr, 2] = s
p_cont_vec[cptr, 8] = int(flat_ctx[SID])
p_cont_vec[cptr, 9] = 1
cptr += 1
elif op == O_REPLACE_EFFECT:
if cptr < 32:
p_cont_vec[cptr, 0] = 46
p_cont_vec[cptr, 1] = v
p_cont_vec[cptr, 2] = s
p_cont_vec[cptr, 9] = 1
cptr += 1
elif op == O_LOOK_DECK:
# Look deck in VM just processes the look (Revealing cards to a buffer)
# For now, we skip if it's purely for selection (handled by 41)
# Implementation: No-op for state, but placeholder for side-effects
continue
elif op == O_REVEAL_CARDS:
# Reveal cards (placeholder for state-based effects)
continue
elif op == O_RECOV_L:
# Heuristic: Recover highest ID Live Card
best_idx = -1
best_id = -1
for tr_k in range(p_trash.shape[0]):
tid = p_trash[tr_k]
if tid > 0:
tbid = get_base_id(tid)
if tbid < card_stats.shape[0] and card_stats[tbid, 10] == 2: # Live
if tid > best_id:
best_id = tid
best_idx = tr_k
if best_idx != -1:
# Move to hand
moved = False
for h_idx in range(60):
if p_hand[h_idx] == 0:
p_hand[h_idx] = best_id
global_ctx[HD] += 1
p_trash[best_idx] = 0
global_ctx[4] -= 1 # TR
moved = True
break
if not moved:
# Hand full: Fallback to discard or stay in trash (which it already is)
continue
elif op == O_RECOV_M:
# Heuristic: Recover highest ID Member Card
best_idx = -1
best_id = -1
for tr_k in range(p_trash.shape[0]):
tid = p_trash[tr_k]
if tid > 0:
tbid = get_base_id(tid)
if tbid < card_stats.shape[0] and card_stats[tbid, 10] == 1: # Member
# Optional: Add Cost/Power check if needed, for now Max ID is decent proxy
if tid > best_id:
best_id = tid
best_idx = tr_k
if best_idx != -1:
# Move to hand
moved = False
for h_idx in range(60):
if p_hand[h_idx] == 0:
p_hand[h_idx] = best_id
global_ctx[HD] += 1
p_trash[best_idx] = 0
global_ctx[4] -= 1 # TR
moved = True
break
elif op == O_ACTIVATE_MEMBER:
if 0 <= s < 3:
p_tapped[s] = 0
elif op == O_SWAP_CARDS:
removed = 0
for h_idx in range(60):
if p_hand[h_idx] > 0:
cid = p_hand[h_idx]
p_hand[h_idx] = 0
global_ctx[HD] -= 1
removed += 1
if removed >= v:
break
if s == 0: # Only draw if mode is 0 (Swap). s=1 implies Discard Only cost.
drawn = 0
for d_idx in range(60):
if p_deck[d_idx] > 0:
card_id = p_deck[d_idx]
p_deck[d_idx] = 0
for h_idx in range(60):
if p_hand[h_idx] == 0:
p_hand[h_idx] = card_id
break
global_ctx[DK] -= 1
global_ctx[HD] += 1
drawn += 1
if drawn >= v:
break
elif op == O_PLACE_UNDER:
placed = 0
if a == 1: # From Energy Zone
for _ in range(v):
if global_ctx[EN] > 0:
global_ctx[EN] -= 1
if 0 <= s < 3:
for e_idx in range(32):
if p_energy_vec[s, e_idx] == 0:
p_energy_vec[s, e_idx] = 2000 # Dummy energy card
p_energy_count[s] += 1
break
placed += 1
if placed >= v:
break
else: # From Hand (Default)
for h_idx in range(59, -1, -1):
if p_hand[h_idx] > 0:
cid = p_hand[h_idx]
p_hand[h_idx] = 0
global_ctx[HD] -= 1
if 0 <= s < 3:
for e_idx in range(32):
if p_energy_vec[s, e_idx] == 0:
p_energy_vec[s, e_idx] = cid
p_energy_count[s] += 1
break
placed += 1
if placed >= v:
break
elif op == O_MOVE_MEMBER:
dest_slot = int(flat_ctx[TS])
if 0 <= s < 3 and 0 <= dest_slot < 3 and s != dest_slot:
temp_id = p_stage[s]
p_stage[s] = p_stage[dest_slot]
p_stage[dest_slot] = temp_id
temp_tap = p_tapped[s]
p_tapped[s] = p_tapped[dest_slot]
p_tapped[dest_slot] = temp_tap
for e_idx in range(32):
temp_e = p_energy_vec[s, e_idx]
p_energy_vec[s, e_idx] = p_energy_vec[dest_slot, e_idx]
p_energy_vec[dest_slot, e_idx] = temp_e
temp_ec = p_energy_count[s]
p_energy_count[s] = p_energy_count[dest_slot]
p_energy_count[dest_slot] = temp_ec
elif op == O_TAP_M:
# Tap self or other member (usually based on TargetSlot/s)
if 0 <= s < 3:
p_tapped[s] = True
elif (
s == 10
): # TargetSlot 10 usually means select manually, but in Numba we might just tap all if instructed
p_tapped[:] = True
elif op == O_TAP_O:
is_all = (a & 0x80) != 0
c_max = v
b_max = a & 0x7F
# Decode real slot
real_slot = s & 0x0F
for slot_k in range(3):
if not is_all and slot_k != real_slot:
continue
cid = opp_stage[slot_k]
if cid >= 0:
bid = get_base_id(cid)
if bid < card_stats.shape[0]:
# Filter checks
if c_max != 99 and card_stats[bid, 0] > c_max:
continue
if b_max != 99 and card_stats[bid, 1] > b_max:
continue
opp_tapped[slot_k] = 1
elif op == O_BUFF:
if cptr < 32:
p_cont_vec[cptr, 0] = 8
p_cont_vec[cptr, 1] = v
p_cont_vec[cptr, 2] = s
p_cont_vec[cptr, 8] = int(flat_ctx[SID])
p_cont_vec[cptr, 9] = 1
cptr += 1
elif op == O_BOOST:
# out_bonus[0] += v # WRONG: This was adding to Permanent Score
global_ctx[LS] += v # CORRECT: Add to Temporary Live Score Context
elif op == O_LOOK_AND_CHOOSE:
choice_idx = int(flat_ctx[CH])
if choice_idx < 0 or choice_idx >= v:
choice_idx = 0
indices = np.full(v, -1, dtype=np.int32)
ptr = 0
for d_idx in range(60):
if p_deck[d_idx] > 0:
indices[ptr] = d_idx
ptr += 1
if ptr >= v:
break
if ptr > 0:
if choice_idx >= ptr:
choice_idx = 0
real_idx = indices[choice_idx]
chosen_card = -1
if real_idx != -1:
chosen_card = p_deck[real_idx]
if chosen_card > 0:
for h_idx in range(60):
if p_hand[h_idx] == 0:
p_hand[h_idx] = chosen_card
global_ctx[HD] += 1
break
for k in range(ptr):
rid = indices[k]
if rid != -1:
cid = p_deck[rid]
p_deck[rid] = 0
global_ctx[DK] -= 1
if rid != real_idx: # Fix: Don't mill the card added to hand
move_to_trash(0, cid, p_trash.reshape(1, -1), global_ctx.reshape(1, -1), 2)
elif op == O_ORDER_DECK:
indices = np.full(v, -1, dtype=np.int32)
vals = np.full(v, 0, dtype=np.int32)
ptr = 0
for d_idx in range(60):
if p_deck[d_idx] > 0:
indices[ptr] = d_idx
vals[ptr] = p_deck[d_idx]
ptr += 1
if ptr >= v:
break
if ptr > 1:
for k in range(ptr // 2):
temp = vals[k]
vals[k] = vals[ptr - 1 - k]
vals[ptr - 1 - k] = temp
for k in range(ptr):
p_deck[indices[k]] = vals[k]
elif op == O_ADD_H:
drawn = 0
for d_idx in range(60):
if p_deck[d_idx] > 0:
card_id = p_deck[d_idx]
p_deck[d_idx] = 0
global_ctx[DK] -= 1
for h_idx in range(60):
if p_hand[h_idx] == 0:
p_hand[h_idx] = card_id
global_ctx[HD] += 1
break
drawn += 1
if drawn >= v:
break
elif op == O_SEARCH_DECK:
target_idx = int(flat_ctx[TS])
if 0 <= target_idx < 60 and p_deck[target_idx] > 0:
card_to_move = p_deck[target_idx]
p_deck[target_idx] = 0
for h_idx in range(60):
if p_hand[h_idx] == 0:
p_hand[h_idx] = card_to_move
global_ctx[HD] += 1
global_ctx[DK] -= 1
break
else:
for d_idx in range(60):
if p_deck[d_idx] > 0:
card_to_move = p_deck[d_idx]
p_deck[d_idx] = 0
for h_idx in range(60):
if p_hand[h_idx] == 0:
p_hand[h_idx] = card_to_move
global_ctx[HD] += 1
global_ctx[DK] -= 1
break
break
elif op == O_MOVE_TO_DISCARD:
# v=count, a=source(1=deck,2=hand,3=energy), s=target(0=self)
if a == 1: # Deck
moved = 0
for d_idx in range(60):
if p_deck[d_idx] > 0:
cid = p_deck[d_idx]
p_deck[d_idx] = 0
global_ctx[DK] -= 1
# Add to trash inline
for tr_k in range(60):
if p_trash[tr_k] == 0:
p_trash[tr_k] = cid
global_ctx[DI] += 1
break
moved += 1
if moved >= v:
break
elif a == 2: # Hand
moved = 0
for h_idx in range(59, -1, -1):
if p_hand[h_idx] > 0:
cid = p_hand[h_idx]
p_hand[h_idx] = 0
global_ctx[HD] -= 1
for tr_k in range(60):
if p_trash[tr_k] == 0:
p_trash[tr_k] = cid
global_ctx[DI] += 1
break
moved += 1
if moved >= v:
break
# a=3 Energy not fully supported in fast_logic yet (requires attached energy iter)
elif s == 0: # Self (Stage)
scid = int(flat_ctx[SID])
for s_k in range(3):
if p_stage[s_k] == scid:
p_stage[s_k] = -1
p_tapped[s_k] = 0
for tr_k in range(60):
if p_trash[tr_k] == 0:
p_trash[tr_k] = scid
global_ctx[DI] += 1
break
break
elif op == O_TRIGGER_REMOTE:
target_slot = s
target_card_id = -1
if 0 <= target_slot < 3:
target_card_id = p_stage[target_slot]
if target_card_id >= 0:
target_bid = get_base_id(target_card_id)
if target_bid < b_idx.shape[0]:
map_idx = b_idx[target_bid, 0]
if map_idx >= 0 and stack_ptr < 3:
stack_ptr += 1
if stack_ptr == 1:
bc1 = b_map[map_idx]
ip1 = 0
elif stack_ptr == 2:
bc2 = b_map[map_idx]
ip2 = 0
else:
bc3 = b_map[map_idx]
ip3 = 0
continue
out_cptr[0] = cptr
@njit(nopython=True, parallel=True, cache=True, fastmath=True)
def batch_resolve_bytecode(
batch_bytecode,
batch_flat_ctx,
batch_global_ctx,
player_id,
p_hand,
p_deck,
p_stage,
p_energy_vec,
p_energy_count,
p_cont_vec,
p_cont_ptr,
p_tapped,
p_live,
opp_tapped,
p_trash,
b_map,
b_idx,
card_stats, # Added: NumPy array for card properties
opp_stage, # Added
):
num_envs = batch_bytecode.shape[0]
# Pre-allocate bonus array to avoid allocations in prange
batch_bonus = np.zeros(num_envs, dtype=np.int32)
for i in prange(num_envs):
cptr_slice = p_cont_ptr[i : i + 1]
out_bonus_slice = batch_bonus[i : i + 1]
resolve_bytecode(
batch_bytecode[i],
batch_flat_ctx[i],
batch_global_ctx[i],
player_id,
p_hand[i],
p_deck[i],
p_stage[i],
p_energy_vec[i],
p_energy_count[i],
p_cont_vec[i],
cptr_slice,
p_tapped[i],
p_live[i],
opp_tapped[i],
p_trash[i],
b_map,
b_idx,
out_bonus_slice,
card_stats,
opp_stage[i], # Pass opponent stage
)
@njit(nopython=True, cache=True, inline="always", fastmath=True)
def copy_state(s_stg, s_ev, s_ec, s_cv, d_stg, d_ev, d_ec, d_cv):
d_stg[:] = s_stg[:]
d_ev[:] = s_ev[:]
d_ec[:] = s_ec[:]
d_cv[:] = s_cv[:]
@njit(nopython=True, cache=True, inline="always", fastmath=True)
def move_to_trash(i, card_id, batch_trash, batch_global_ctx, TR_idx):
if card_id <= 0:
return
for k in range(batch_trash.shape[1]):
if batch_trash[i, k] == 0:
batch_trash[i, k] = card_id
batch_global_ctx[i, TR_idx] += 1
break
@njit(nopython=True, cache=True, fastmath=True)
def resolve_live_single(
i,
live_id,
batch_stage,
batch_live,
batch_scores,
batch_global_ctx,
batch_deck,
batch_hand,
batch_trash,
card_stats,
p_cont_vec,
p_cont_ptr,
b_map,
b_idx,
p_tapped,
):
# Rule 8.3.4: Non-Live cards are discarded before performance
live_bid = get_base_id(live_id)
if live_bid >= card_stats.shape[0] or card_stats[live_bid, 10] != 2:
# Find and remove from live zone
for j in range(batch_live.shape[1]):
if batch_live[i, j] == live_id:
move_to_trash(i, live_id, batch_trash, batch_global_ctx, 2)
batch_live[i, j] = 0
break
return 0
# 1. Verify availability in Live Zone
live_idx = -1
for j in range(batch_live.shape[1]):
if batch_live[i, j] == live_id:
live_idx = j
break
if live_idx == -1:
return 0
batch_global_ctx[i, LS] = 0 # Ensure reset score bonus
batch_global_ctx[i, EH] = 0 # Reset excess hearts
# 1. Trigger ON_LIVE_START (TriggerType = 2)
for ab_idx in range(4):
trigger_off = 20 if ab_idx == 0 else (20 + 16 + (ab_idx - 1) * 12)
if card_stats[live_bid, trigger_off] == 2: # ON_LIVE_START
map_idx = b_idx[live_bid, ab_idx]
if map_idx >= 0:
# Execution context
flat_ctx = np.zeros(64, dtype=np.int32)
dummy_opp_tapped = np.zeros(3, dtype=np.int32)
out_bonus = np.zeros(1, dtype=np.int32)
p_tapped_dummy = np.zeros(16, dtype=np.int32)
resolve_bytecode(
b_map[map_idx],
flat_ctx,
batch_global_ctx[i],
0,
batch_hand[i],
batch_deck[i],
batch_stage[i],
np.zeros((3, 32), dtype=np.int32),
np.zeros(3, dtype=np.int32),
p_cont_vec[i],
p_cont_ptr[i : i + 1],
p_tapped,
batch_live[i],
dummy_opp_tapped,
batch_trash[i],
b_map,
b_idx,
out_bonus,
card_stats,
batch_stage[i],
)
# 2. Check Requirements
# Get Live Stats (indices 12-18 for requirements)
req_pink = card_stats[live_bid, 12]
req_red = card_stats[live_bid, 13]
req_yel = card_stats[live_bid, 14]
req_grn = card_stats[live_bid, 15]
req_blu = card_stats[live_bid, 16]
req_pur = card_stats[live_bid, 17]
# Sum Stage Stats
stage_hearts = np.zeros(7, dtype=np.int32)
stage_all = 0
total_blades = 0
for slot in range(3):
# Rule 9.9: Resting members (tapped) do not contribute to performance.
cid = batch_stage[i, slot]
if cid >= 0 and p_tapped[slot] == 0:
bid = get_base_id(cid)
if bid < card_stats.shape[0]:
slot_blades = card_stats[bid, 1]
slot_hearts = np.zeros(7, dtype=np.int32)
for cidx in range(6):
slot_hearts[cidx] = card_stats[bid, 12 + cidx]
slot_all = card_stats[bid, 18]
# Apply continuous effects
for ce_idx in range(p_cont_ptr[i]):
op = p_cont_vec[i, ce_idx, 0]
val = p_cont_vec[i, ce_idx, 1]
target_slot = p_cont_vec[i, ce_idx, 2]
active = p_cont_vec[i, ce_idx, 9]
if active == 1 and (target_slot == -1 or target_slot == slot):
if op == 1 or op == 8: # BLADES / BUFF
slot_blades += val
elif op == 24: # SET_BLADES
slot_blades = val
elif op == 2: # HEARTS
a_color = p_cont_vec[i, ce_idx, 5]
if 0 <= a_color <= 5:
slot_hearts[a_color] += val
elif a_color == 6: # Any/Rainbow
slot_all += val
total_blades += max(0, slot_blades)
for cidx in range(6):
stage_hearts[cidx] += slot_hearts[cidx]
stage_all += slot_all
# Apply Heart Requirement Reductions
for ce_idx in range(p_cont_ptr[i]):
op = p_cont_vec[i, ce_idx, 0]
val = p_cont_vec[i, ce_idx, 1]
active = p_cont_vec[i, ce_idx, 9]
if active == 1 and op == 48: # REDUCE_HEART_REQ
target_color = p_cont_vec[i, ce_idx, 2] # 0-5 or 6 (Any)
if target_color == 0:
req_pink = max(0, req_pink - val)
elif target_color == 1:
req_red = max(0, req_red - val)
elif target_color == 2:
req_yel = max(0, req_yel - val)
elif target_color == 3:
req_grn = max(0, req_grn - val)
elif target_color == 4:
req_blu = max(0, req_blu - val)
elif target_color == 5:
req_pur = max(0, req_pur - val)
elif target_color == 6: # Reduction for 'Any' requirement
# In this simplified model, card_stats[live_bid, 18] might be 'Any'
# but it's not being checked yet. Let's assume for now
# that we don't have a specific index for 'Any' req in card_stats.
# Actually, let's look for where 'any' might be.
pass
# --- RULE ACCURACY: Yells (Rule 8.3) ---
# Draw 'total_blades' cards from deck, apply their blade_hearts and volume/draw icons.
volume_bonus = 0
draw_bonus = 0
yells_processed = 0
# Use a while loop to handle potential deck refreshes
while yells_processed < total_blades:
# Check if we need to refresh (deck empty/exhausted)
# Scan for next valid card
found_card = False
deck_len = batch_deck.shape[1]
d_idx = -1
for k in range(deck_len):
if batch_deck[i, k] > 0:
d_idx = k
found_card = True
break
if not found_card:
# Deck is empty, trigger refresh logic
check_deck_refresh(i, batch_deck, batch_trash, batch_global_ctx, 6, 2)
# Try to find again
for k in range(deck_len):
if batch_deck[i, k] > 0:
d_idx = k
found_card = True
break
# If still no card (Deck + Trash were empty), we stop yelling.
if not found_card:
break
# Process the found card
if d_idx >= 0:
yid = batch_deck[i, d_idx]
if yid > 0 and yid < card_stats.shape[0]:
# Extract blade_hearts [40:47]
bh = np.zeros(7, dtype=np.int32)
for cidx in range(7):
bh[cidx] = card_stats[yid, 40 + cidx]
# Apply TRANSFORM_COLOR (Rule 11.12)
for ce_idx in range(p_cont_ptr[i]):
if p_cont_vec[i, ce_idx, 0] == O_TRANSFORM_COLOR:
target_idx = p_cont_vec[i, ce_idx, 2]
if 0 <= target_idx <= 5:
total_affected = 0
# Transform Pink, Red, Yellow, Green, Blue, (and All if applicable)
# Rule for Dazzling Game: 0,1,2,3,4,6 -> target_idx
for src_idx in [0, 1, 2, 3, 4, 6]:
total_affected += bh[src_idx]
bh[src_idx] = 0
bh[target_idx] += total_affected
# Add to totals
for cidx in range(7):
stage_hearts[cidx] += bh[cidx]
# Bonus Icons
volume_bonus += card_stats[yid, 4]
draw_bonus += card_stats[yid, 5]
# Discard (Remove from deck)
batch_deck[i, d_idx] = 0
if batch_global_ctx[i, 6] > 0:
batch_global_ctx[i, 6] -= 1 # DK
move_to_trash(i, yid, batch_trash, batch_global_ctx, 2)
yells_processed += 1
else:
# Invalid card ID or padding
batch_deck[i, d_idx] = 0
yells_processed += 1
else:
break
# Apply Draw Bonus
cards_drawn = 0
while cards_drawn < draw_bonus:
found_card = False
d_idx = -1
deck_len = batch_deck.shape[1]
for k in range(deck_len):
if batch_deck[i, k] > 0:
d_idx = k
found_card = True
break
if not found_card:
check_deck_refresh(i, batch_deck, batch_trash, batch_global_ctx, 6, 2)
for k in range(deck_len):
if batch_deck[i, k] > 0:
d_idx = k
found_card = True
break
if not found_card:
break
if found_card and d_idx != -1:
# Draw the card
top_c = batch_deck[i, d_idx]
# Move to Hand
placed = False
for h_ptr in range(batch_hand.shape[1]):
if batch_hand[i, h_ptr] == 0:
batch_hand[i, h_ptr] = top_c
placed = True
break
# Remove from Deck regardless (it left the deck)
batch_deck[i, d_idx] = 0
if batch_global_ctx[i, 6] > 0:
batch_global_ctx[i, 6] -= 1 # DK
if placed:
batch_global_ctx[i, 3] += 1 # HD
else:
# Hand full, discard to trash
move_to_trash(i, top_c, batch_trash, batch_global_ctx, 2)
else:
break
cards_drawn += 1
# Apply Heart Requirement Reductions
req_any = card_stats[live_bid, 18]
for ce_idx in range(p_cont_ptr[i]):
op = p_cont_vec[i, ce_idx, 0]
val = p_cont_vec[i, ce_idx, 1]
active = p_cont_vec[i, ce_idx, 9]
if active == 1 and op == 48: # REDUCE_HEART_REQ
target_color = p_cont_vec[i, ce_idx, 2] # 0-5 or 6 (Any)
if target_color == 0:
req_pink = max(0, req_pink - val)
elif target_color == 1:
req_red = max(0, req_red - val)
elif target_color == 2:
req_yel = max(0, req_yel - val)
elif target_color == 3:
req_grn = max(0, req_grn - val)
elif target_color == 4:
req_blu = max(0, req_blu - val)
elif target_color == 5:
req_pur = max(0, req_pur - val)
elif target_color == 6:
req_any = max(0, req_any - val)
# Verify Requirements (Greedy points matching)
met = True
temp_all = stage_all
req_list = [req_pink, req_red, req_yel, req_grn, req_blu, req_pur]
for cidx in range(6):
needed = req_list[cidx]
have = stage_hearts[cidx]
if have < needed:
deficit = needed - have
if temp_all >= deficit:
temp_all -= deficit
else:
met = False
break
if met:
remaining = temp_all
for cidx in range(6):
remaining += max(0, stage_hearts[cidx] - req_list[cidx])
if remaining < req_any:
met = False
# 3. Apply Result (Defer to Battle Phase)
if met and total_blades > 0:
# SUCCESS PENDING
# --- RULE ACCURACY: Excess Hearts (Rule 8.3) ---
total_p_hearts = (
stage_hearts[0]
+ stage_hearts[1]
+ stage_hearts[2]
+ stage_hearts[3]
+ stage_hearts[4]
+ stage_hearts[5]
+ stage_all
)
req_total = req_pink + req_red + req_yel + req_grn + req_blu + req_pur
batch_global_ctx[i, EH] = max(0, total_p_hearts - req_total)
# 4. Trigger ON_LIVE_SUCCESS (TriggerType = 3)
for ab_idx in range(4):
trigger_off = 20 if ab_idx == 0 else (20 + 16 + (ab_idx - 1) * 12)
if card_stats[live_id, trigger_off] == 3: # ON_LIVE_SUCCESS
map_idx = b_idx[live_id, ab_idx]
if map_idx >= 0:
# Execution context
flat_ctx = np.zeros(64, dtype=np.int32)
dummy_opp_tapped = np.zeros(3, dtype=np.int32)
out_bonus_ab = np.zeros(1, dtype=np.int32)
p_tapped_dummy = np.zeros(16, dtype=np.int32)
resolve_bytecode(
b_map[map_idx],
flat_ctx,
batch_global_ctx[i],
0,
batch_hand[i],
batch_deck[i],
batch_stage[i],
np.zeros((3, 32), dtype=np.int32),
np.zeros(3, dtype=np.int32),
p_cont_vec[i],
p_cont_ptr[i : i + 1],
p_tapped_dummy,
batch_live[i],
dummy_opp_tapped,
batch_trash[i],
b_map,
b_idx,
out_bonus_ab,
card_stats,
opp_stage[i], # Opponent stage
)
base_score = card_stats[live_id, 38]
if base_score <= 0:
base_score = 1 # Safety
total_score = base_score + volume_bonus + batch_global_ctx[i, LS]
batch_global_ctx[i, LS] = 0 # Reset after applying
batch_global_ctx[i, EH] = 0 # Reset after triggering success
# Clear Stage MEMBERS (Rule 8.3.17) - Members are cleared after performance regardless of battle result?
# Rule 8.4.8 says "Clear ... remaining cards". Rule 8.3.17 says "Clear stage".
# Assume stage clear happens here.
for slot in range(3):
cid = batch_stage[i, slot]
if cid > 0:
move_to_trash(i, cid, batch_trash, batch_global_ctx, 2)
batch_stage[i, slot] = -1
# We DO NOT remove the live card yet. It waits for battle.
return total_score
else:
# FAILURE - Cards in zone are discarded after performance (Rule 8.3.16)
if live_idx >= 0:
move_to_trash(i, live_id, batch_trash, batch_global_ctx, 2)
batch_live[i, live_idx] = 0
# Also clear stage on failure
for slot in range(3):
cid = batch_stage[i, slot]
if cid > 0:
move_to_trash(i, cid, batch_trash, batch_global_ctx, 2)
batch_stage[i, slot] = -1
return 0
@njit(nopython=True)
def p_deck_len_helper(batch_deck, i):
return batch_deck.shape[1]
@njit(nopython=True)
def p_hand_len_helper(batch_hand, i):
return batch_hand.shape[1]
@njit(nopython=True, cache=True)
def check_deck_refresh(i, p_deck, p_trash, g_ctx, DK_idx, TR_idx):
# Rule 10.2: If deck empty and trash has cards, refresh.
if g_ctx[i, DK_idx] <= 0 and g_ctx[i, TR_idx] > 0:
# Move trash to deck
d_ptr = 0
for k in range(p_trash.shape[1]):
cid = p_trash[i, k]
if cid > 0:
p_deck[i, d_ptr] = cid
p_trash[i, k] = 0
d_ptr += 1
g_ctx[i, DK_idx] = d_ptr
g_ctx[i, TR_idx] = 0
# Shuffle Deck
if d_ptr > 1:
for k in range(d_ptr - 1, 0, -1):
j = np.random.randint(0, k + 1)
tmp = p_deck[i, k]
p_deck[i, k] = p_deck[i, j]
p_deck[i, j] = tmp
@njit(nopython=True, cache=True, fastmath=True)
def resolve_live_performance(
num_envs: int,
action_ids: np.ndarray,
batch_stage: np.ndarray,
batch_live: np.ndarray,
batch_scores: np.ndarray,
batch_global_ctx: np.ndarray,
batch_deck: np.ndarray,
batch_hand: np.ndarray,
batch_trash: np.ndarray,
card_stats: np.ndarray,
batch_cont_vec: np.ndarray,
batch_cont_ptr: np.ndarray,
batch_tapped: np.ndarray,
b_map: np.ndarray,
b_idx: np.ndarray,
):
for i in range(num_envs):
resolve_live_single(
i,
action_ids[i],
batch_stage,
batch_live,
batch_scores,
batch_global_ctx,
batch_deck,
batch_hand,
batch_trash,
card_stats,
batch_cont_vec[i],
batch_cont_ptr[i : i + 1],
b_map,
b_idx,
batch_tapped[i],
)
@njit(nopython=True, parallel=True, cache=True, fastmath=True)
def batch_apply_action(
actions,
pid,
p_stg,
p_ev,
p_ec,
p_cv,
p_cp,
p_tap,
p_sb,
p_lr,
o_tap,
f_ctx_batch,
g_ctx_batch,
p_h,
p_d,
p_tr, # Added
b_map,
b_idx,
card_stats,
):
num_envs = actions.shape[0]
batch_delta_bonus = np.zeros(num_envs, dtype=np.int32)
for i in prange(num_envs):
g_ctx_batch[i, SC] = p_sb[i]
act_id = actions[i]
if act_id == 0:
# Pass triggers Performance Phase (Rule 8) for all set lives
for z_idx in range(10): # Limit scan
lid = p_lr[i, z_idx]
if lid > 0:
resolve_live_single(
i,
lid,
p_stg,
p_lr,
p_sb,
g_ctx_batch,
p_d,
p_h,
p_tr,
card_stats,
p_cv[i],
p_cp[i],
b_map,
b_idx,
p_tap[i],
)
g_ctx_batch[i, PH] = 8
elif 1 <= act_id <= 180:
# Member play logic... (Keeping original stable version)
adj = act_id - 1
hand_idx = adj // 3
slot = adj % 3
if hand_idx < p_h.shape[1]:
card_id = p_h[i, hand_idx]
if card_id > 0 and card_id < card_stats.shape[0]:
cost = card_stats[card_id, 0]
effective_cost = cost
prev_cid = p_stg[i, slot]
if prev_cid >= 0 and prev_cid < card_stats.shape[0]:
prev_cost = card_stats[prev_cid, 0]
effective_cost = cost - prev_cost
if effective_cost < 0:
effective_cost = 0
# Rule 6.4.1 & Parity with get_member_cost: Substract continuous cost reductions
total_reduction = 0
for ce_k in range(p_cp[i, 0]): # p_cp is ptr array
if p_cv[i, ce_k, 0] == 3: # REDUCE_COST type
total_reduction += p_cv[i, ce_k, 1]
effective_cost -= total_reduction
if effective_cost < 0:
effective_cost = 0
# Capture the ID of the card being replaced (if any) for Baton Pass
prev_cid = p_stg[i, slot]
g_ctx_batch[i, PREV_CID_IDX] = prev_cid
ec = g_ctx_batch[i, EN]
if ec > 12:
ec = 12
paid = 0
if effective_cost > 0:
for e_idx in range(ec):
if 3 + e_idx < 16:
if p_tap[i, 3 + e_idx] == 0:
p_tap[i, 3 + e_idx] = 1
paid += 1
if paid >= effective_cost:
break
else:
break
if prev_cid > 0:
move_to_trash(i, prev_cid, p_tr, g_ctx_batch, 2)
p_stg[i, slot] = card_id
# Fix Duplication: Remove from hand
p_h[i, hand_idx] = 0
g_ctx_batch[i, 3] -= 1
# Resolve Auto-Effects (On Play)
if card_id > 0 and card_id < b_idx.shape[0]:
map_idx = b_idx[card_id, 0]
g_ctx_batch[i, 51 + slot] = 1
if card_id < b_idx.shape[0]:
map_idx = b_idx[card_id, 0]
if map_idx >= 0:
code_seq = b_map[map_idx]
f_ctx_batch[i, 7] = 1
f_ctx_batch[i, SID] = card_id
p_cp_slice = p_cp[i : i + 1]
out_bonus_slice = batch_delta_bonus[i : i + 1]
out_bonus_slice[0] = 0
resolve_bytecode(
code_seq,
f_ctx_batch[i],
g_ctx_batch[i],
pid,
p_h[i],
p_d[i],
p_stg[i],
p_ev[i],
p_ec[i],
p_cv[i],
p_cp_slice,
p_tap[i],
p_lr[i],
o_tap[i],
p_tr[i],
b_map,
b_idx,
out_bonus_slice,
card_stats,
o_tap[i],
)
p_sb[i] += out_bonus_slice[0]
f_ctx_batch[i, 7] = 0
elif 200 <= act_id <= 202:
# Activation logic...
slot = act_id - 200
card_id = p_stg[i, slot]
if card_id >= 0 and card_id < b_idx.shape[0]:
map_idx = b_idx[card_id, 0]
if map_idx >= 0:
code_seq = b_map[map_idx]
f_ctx_batch[i, 7] = 1
f_ctx_batch[i, SID] = card_id
p_cp_slice = p_cp[i : i + 1]
out_bonus_slice = batch_delta_bonus[i : i + 1]
out_bonus_slice[0] = 0
resolve_bytecode(
code_seq,
f_ctx_batch[i],
g_ctx_batch[i],
pid,
p_h[i],
p_d[i],
p_stg[i],
p_ev[i],
p_ec[i],
p_cv[i],
p_cp_slice,
p_tap[i],
p_lr[i],
o_tap[i],
p_tr[i],
b_map,
b_idx,
out_bonus_slice,
card_stats,
o_tap[i],
)
p_sb[i] += out_bonus_slice[0]
f_ctx_batch[i, 7] = 0
p_tap[i, slot] = 1
elif 400 <= act_id <= 459:
# Set Live Card from Hand (Rule 8.3)
hand_idx = act_id - 400
if hand_idx < p_h.shape[1]:
card_id = p_h[i, hand_idx]
if card_id > 0: # Allow any card (Rule 8.3 & 8.2.2)
# Find empty slot in live zone (max 3)
for z_idx in range(p_lr.shape[1]):
if p_lr[i, z_idx] == 0:
p_lr[i, z_idx] = card_id
p_h[i, hand_idx] = 0
g_ctx_batch[i, 3] -= 1 # HD
# Rule 8.2.2: Draw 1 card after placing
# Check Refresh first
check_deck_refresh(i, p_d, p_tr, g_ctx_batch, 6, 2)
# Find top card
d_idx = -1
for k in range(p_d.shape[1]): # Fixed 60 -> shape[1]
if p_d[i, k] > 0:
d_idx = k
break
if d_idx != -1:
top_c = p_d[i, d_idx]
p_d[i, d_idx] = 0
g_ctx_batch[i, 6] -= 1 # DK
# Add to hand
placed = False
for h_ptr in range(p_h.shape[1]):
if p_h[i, h_ptr] == 0:
p_h[i, h_ptr] = top_c
g_ctx_batch[i, 3] += 1 # HD
placed = True
break
if not placed:
# Hand full, discard
move_to_trash(i, top_c, p_tr, g_ctx_batch, 2)
break
elif 500 <= act_id <= 559: # STANDARD Hand Selection
f_ctx_batch[i, 15] = act_id - 500
g_ctx_batch[i, 8] = 4
elif 600 <= act_id <= 611: # STANDARD Energy Selection
f_ctx_batch[i, 15] = act_id - 600
g_ctx_batch[i, 8] = 4
elif 100 <= act_id <= 159: # ATTENTION Hand Selection
f_ctx_batch[i, 15] = act_id - 100
g_ctx_batch[i, 8] = 4
elif 160 <= act_id <= 171: # ATTENTION Energy Selection
f_ctx_batch[i, 15] = act_id - 160
g_ctx_batch[i, 8] = 4
p_sb[i] = g_ctx_batch[i, SC]
@njit(nopython=True, cache=True, fastmath=True)
def apply_action(aid, pid, p_stg, p_ev, p_ec, p_cv, p_cp, p_tap, p_sb, p_lr, o_tap, f_ctx, g_ctx, p_h, p_d, p_tr):
# Specialized fast-path for Action 1 (Simulation)
if aid == 1:
bc = np.zeros((1, 4), dtype=np.int32)
bc[0, 0] = 11 # O_BLADES
bc[0, 1] = 1
bc[0, 3] = 0
d_map = np.zeros((1, 1, 4), dtype=np.int32)
d_idx = np.zeros((1, 4), dtype=np.int32)
cptr_arr = np.array([p_cp], dtype=np.int32)
bn_arr = np.zeros(1, dtype=np.int32)
resolve_bytecode(
bc,
f_ctx,
g_ctx,
pid,
p_h,
p_d,
p_stg,
p_ev,
p_ec,
p_cv,
cptr_arr,
p_tap,
p_lr,
o_tap,
p_tr, # Pass trash
d_map,
d_idx,
bn_arr,
o_tap, # Pass opp_tapped count
)
return cptr_arr[0], p_sb + bn_arr[0]
return p_cp, p_sb
@njit(nopython=True, cache=True)
def _move_to_trash_single(card_id, p_trash, p_global_ctx, TR_idx):
if card_id <= 0:
return
for k in range(p_trash.shape[0]):
if p_trash[k] == 0:
p_trash[k] = card_id
p_global_ctx[TR_idx] += 1
break
@njit(nopython=True, cache=True)
def _check_deck_refresh_single(p_deck, p_trash, p_global_ctx, DK_idx, TR_idx):
if p_global_ctx[DK_idx] <= 0 and p_global_ctx[TR_idx] > 0:
# Move trash to deck
d_ptr = 0
for k in range(p_trash.shape[0]):
if p_trash[k] > 0:
p_deck[d_ptr] = p_trash[k]
p_trash[k] = 0
d_ptr += 1
p_global_ctx[DK_idx] = d_ptr
p_global_ctx[TR_idx] = 0
# Shuffle
if d_ptr > 1:
for k in range(d_ptr - 1, 0, -1):
j = np.random.randint(0, k + 1)
tmp = p_deck[k]
p_deck[k] = p_deck[j]
p_deck[j] = tmp
@njit(nopython=True, cache=True)
def select_heuristic_action(
p_hand,
p_deck,
p_stage,
p_energy_vec,
p_energy_count,
p_tapped,
p_live,
p_scores,
p_global_ctx,
p_trash,
o_tapped,
card_stats,
bytecode_index,
):
"""
Selects a single heuristic action for the given player state.
Returns: action_id (int)
"""
# --- 1. Select Action (Score-Based Heuristic) ---
best_action = 0
best_score = -1.0
# A. Hand Actions (Play Member / Set Live)
for h in range(p_hand.shape[0]):
cid = p_hand[h]
if cid > 0:
if cid > 900: # Live Card (Simplified Check > 900)
# Should verify type in card_stats if available, but >900 heuristic is okay for now
if card_stats[cid, 10] == 2: # Live
# Prefer setting live
empty_live = 0
for z in range(p_live.shape[0]):
if p_live[z] == 0:
empty_live += 1
if empty_live > 0:
score = 100.0 + np.random.random() * 10.0 # High priority
if score > best_score:
best_score = score
best_action = 400 + h
else: # Member Card
if card_stats[cid, 10] == 1: # Member
cost = card_stats[cid, 0]
ec = p_global_ctx[5] # EN
if ec >= cost:
score = (cost * 15.0) + (np.random.random() * 5.0) # Favor high cost
# If we have lots of energy, prioritize spending it
if ec > 5:
score += 5.0
# Check slots
for s in range(3):
# Prefer empty slots or replacing weak low-cost cards
prev_cid = p_stage[s]
effective_cost = cost
if prev_cid > 0:
prev_cost = card_stats[prev_cid, 0]
effective_cost = max(0, cost - prev_cost)
# Bonus for upgrading
if cost > prev_cost:
score += 10.0
# Tapping Check (simplified heuristic)
# Assume if we have EC >= EffCost, we can pay.
if ec >= effective_cost:
current_act = (h * 3) + s + 1
if score > best_score:
best_score = score
best_action = current_act
# B. Stage Actions (Activate)
for s in range(3):
cid = p_stage[s]
if cid > 0 and not p_tapped[s]:
if cid < bytecode_index.shape[0]:
if bytecode_index[cid, 0] >= 0:
# Activation is usually good (draw, boost, etc.)
score = 25.0 + np.random.random() * 5.0
if score > best_score:
best_score = score
best_action = 200 + s
return best_action
@njit(nopython=True, cache=True)
def run_opponent_turn_loop(
p_hand,
p_deck,
p_stage,
p_energy_vec,
p_energy_count,
p_tapped,
p_live,
p_scores,
p_global_ctx,
p_trash,
p_continuous_vec,
p_continuous_ptr,
o_tapped,
card_stats,
bytecode_map,
bytecode_index,
):
"""
Simulates a full opponent turn by looping actions until Pass (0) is chosen.
Operating on single-environment slices (1D/2D).
"""
# Safety limit to prevent infinite loops
for step_count in range(20):
action = select_heuristic_action(
p_hand,
p_deck,
p_stage,
p_energy_vec,
p_energy_count,
p_tapped,
p_live,
p_scores,
p_global_ctx,
p_trash,
o_tapped,
card_stats,
bytecode_index,
)
# --- 2. Execute Action ---
if action == 0:
return
if 1 <= action <= 180:
adj = action - 1
hand_idx = adj // 3
slot = adj % 3
if hand_idx < p_hand.shape[0]:
card_id = p_hand[hand_idx]
if card_id > 0:
cost = card_stats[card_id, 0]
prev_cid = p_stage[slot]
effective_cost = cost
if prev_cid > 0:
prev_cost = card_stats[prev_cid, 0]
effective_cost = max(0, cost - prev_cost)
untapped_e = np.zeros(16, dtype=np.int32)
ue_ptr = 0
en_count = p_global_ctx[5]
for e_idx in range(en_count):
if 3 + e_idx < p_tapped.shape[0]:
if p_tapped[3 + e_idx] == 0:
untapped_e[ue_ptr] = 3 + e_idx
ue_ptr += 1
can_pay = True
if ue_ptr < effective_cost:
can_pay = False
if can_pay:
# Pay
for p_idx in range(effective_cost):
tap_idx = untapped_e[p_idx]
p_tapped[tap_idx] = 1
# Move prev_cid to trash (if it exists)
_move_to_trash_single(prev_cid, p_trash, p_global_ctx, 2)
# Capture prev_cid for Baton Pass logic
p_global_ctx[60] = prev_cid
p_stage[slot] = card_id
p_hand[hand_idx] = 0
p_global_ctx[3] -= 1 # HD
# Note: g_ctx must be passed correctly if resolve_bytecode needs it
p_global_ctx[51 + slot] = 1 # Mark played
if card_id < bytecode_index.shape[0]:
map_idx = bytecode_index[card_id, 0]
if map_idx >= 0:
d_bonus = np.zeros(1, dtype=np.int32)
# p_continuous_vec is (32, 10). p_continuous_ptr is (1,)
# resolve_bytecode expects p_cp_slice as (1,)
resolve_bytecode(
bytecode_map[map_idx],
np.zeros(64, dtype=np.int32),
p_global_ctx,
1,
p_hand,
p_deck,
p_stage,
p_energy_vec,
p_energy_count,
p_continuous_vec,
p_continuous_ptr,
p_tapped,
p_live,
o_tapped,
p_trash,
bytecode_map,
bytecode_index,
d_bonus,
card_stats,
o_tapped,
)
p_scores[0] += d_bonus[0]
elif 200 <= action <= 202:
slot = action - 200
card_id = p_stage[slot]
if card_id > 0 and p_tapped[slot] == 0:
if card_id < bytecode_index.shape[0]:
map_idx = bytecode_index[card_id, 0]
if map_idx >= 0:
d_bonus = np.zeros(1, dtype=np.int32)
resolve_bytecode(
bytecode_map[map_idx],
np.zeros(64, dtype=np.int32),
p_global_ctx,
1,
p_hand,
p_deck,
p_stage,
p_energy_vec,
p_energy_count,
p_continuous_vec,
p_continuous_ptr,
p_tapped,
p_live,
o_tapped,
p_trash,
bytecode_map,
bytecode_index,
d_bonus,
card_stats,
o_tapped,
)
p_scores[0] += d_bonus[0]
p_tapped[slot] = 1
elif 400 <= action <= 459:
hand_idx = action - 400
if hand_idx < p_hand.shape[0]:
card_id = p_hand[hand_idx]
if card_id > 0:
l_slot = -1
for z in range(p_live.shape[0]):
if p_live[z] == 0:
l_slot = z
break
if l_slot != -1:
p_live[l_slot] = card_id
p_hand[hand_idx] = 0
p_global_ctx[3] -= 1
_check_deck_refresh_single(p_deck, p_trash, p_global_ctx, 6, 2)
d_idx = -1
for k in range(p_deck.shape[0]):
if p_deck[k] > 0:
d_idx = k
break
if d_idx != -1:
top_c = p_deck[d_idx]
p_deck[d_idx] = 0
p_global_ctx[6] -= 1
placed = False
for h in range(p_hand.shape[0]):
if p_hand[h] == 0:
p_hand[h] = top_c
p_global_ctx[3] += 1
placed = True
break
if not placed:
_move_to_trash_single(top_c, p_trash, p_global_ctx, 2)
@njit(nopython=True, cache=True)
def run_random_turn_loop(
p_hand,
p_deck,
p_stage,
p_energy_vec,
p_energy_count,
p_tapped,
p_live,
p_scores,
p_global_ctx,
p_trash,
p_continuous_vec,
p_continuous_ptr,
o_tapped,
card_stats,
bytecode_map,
bytecode_index,
):
"""
Simulates a full opponent turn by selecting random legal actions.
"""
for step_count in range(20): # Safety limit
# Gather legal candidates
candidates = [0] # Always can Pass
# 1. Member Play Candidates
ec = p_global_ctx[5]
for h in range(p_hand.shape[0]):
cid = p_hand[h]
if cid > 0 and cid < 900:
cost = card_stats[cid, 0]
for s in range(3):
prev_cid = p_stage[s]
effective_cost = cost
if prev_cid > 0:
prev_cost = card_stats[prev_cid, 0]
effective_cost = max(0, cost - prev_cost)
if ec >= effective_cost:
candidates.append((h * 3) + s + 1)
# 2. Activate Candidates
for s in range(3):
cid = p_stage[s]
if cid > 0 and not p_tapped[s]:
if cid < bytecode_index.shape[0]:
if bytecode_index[cid, 0] >= 0:
candidates.append(200 + s)
# 3. Live Set Candidates
empty_live = 0
for z in range(p_live.shape[0]):
if p_live[z] == 0:
empty_live += 1
if empty_live > 0:
for h in range(p_hand.shape[0]):
cid = p_hand[h]
if cid > 900:
candidates.append(400 + h)
# Pick one
idx = np.random.randint(0, len(candidates))
action = candidates[idx]
if action == 0:
return
# Execute
if 1 <= action <= 180:
adj = action - 1
hand_idx = adj // 3
slot = adj % 3
card_id = p_hand[hand_idx]
cost = card_stats[card_id, 0]
prev_cid = p_stage[slot]
effective_cost = cost
if prev_cid > 0:
prev_cost = card_stats[prev_cid, 0]
_move_to_trash_single(prev_cid, p_trash, p_global_ctx, 2)
# Capture prev_cid for Baton Pass logic
p_global_ctx[60] = prev_cid
effective_cost = max(0, cost - prev_cost)
# Pay
p_tapped[3:] = 0 # Dummy untap logic? No, we should use real payment
# Actually run_opponent_turn_loop has a payment block, I'll simplify here
p_global_ctx[5] -= effective_cost
p_stage[slot] = card_id
p_hand[hand_idx] = 0
p_global_ctx[3] -= 1
if card_id < bytecode_index.shape[0]:
map_idx = bytecode_index[card_id, 0]
if map_idx >= 0:
d_bonus = np.zeros(1, dtype=np.int32)
resolve_bytecode(
bytecode_map[map_idx],
np.zeros(64, dtype=np.int32),
p_global_ctx,
1,
p_hand,
p_deck,
p_stage,
p_energy_vec,
p_energy_count,
p_continuous_vec,
p_continuous_ptr,
p_tapped,
p_live,
o_tapped,
p_trash,
bytecode_map,
bytecode_index,
d_bonus,
card_stats,
o_tapped,
)
p_scores[0] += d_bonus[0]
elif 200 <= action <= 202:
slot = action - 200
cid = p_stage[slot]
if cid > 0:
d_bonus = np.zeros(1, dtype=np.int32)
if cid < bytecode_index.shape[0]:
map_idx = bytecode_index[cid, 0]
if map_idx >= 0:
resolve_bytecode(
bytecode_map[map_idx],
np.zeros(64, dtype=np.int32),
p_global_ctx,
1,
p_hand,
p_deck,
p_stage,
p_energy_vec,
p_energy_count,
p_continuous_vec,
p_continuous_ptr,
p_tapped,
p_live,
o_tapped,
p_trash,
bytecode_map,
bytecode_index,
d_bonus,
card_stats,
o_tapped,
)
p_scores[0] += d_bonus[0]
p_tapped[slot] = 1
elif 400 <= action <= 459:
hand_idx = action - 400
card_id = p_hand[hand_idx]
l_slot = -1
for z in range(p_live.shape[0]):
if p_live[z] == 0:
l_slot = z
break
if l_slot != -1:
p_live[l_slot] = card_id
p_hand[hand_idx] = 0
p_global_ctx[3] -= 1
_check_deck_refresh_single(p_deck, p_trash, p_global_ctx, 6, 2)
# Draw 1
for k in range(p_deck.shape[0]):
if p_deck[k] > 0:
top_c = p_deck[k]
p_deck[k] = 0
p_global_ctx[DK] -= 1
for h in range(p_hand.shape[0]):
if p_hand[h] == 0:
p_hand[h] = top_c
p_global_ctx[HD] += 1
break
break