LovecaSim / backend /rust_serializer.py
trioskosmos's picture
Upload folder using huggingface_hub
b872b7e verified
import json
import os
import sys
# --- PATH SETUP ---
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
import engine_rust
from engine.game.desc_utils import get_action_desc
TRIGGER_ICONS = {
1: "ใ€็™ปๅ ดใ€‘",
2: "ใ€ใƒฉใ‚คใƒ–้–‹ๅง‹ใ€‘",
3: "ใ€ใƒฉใ‚คใƒ–ๆˆๅŠŸๆ™‚ใ€‘",
6: "ใ€ๅธธๆ™‚ใ€‘",
7: "ใ€่ตทๅ‹•ใ€‘",
}
class RustCompatPlayer:
def __init__(self, p):
self._p = p
self.player_id = p.player_id
self.hand = p.hand
self.discard = p.discard
self.success_lives = p.success_lives
self.stage = p.stage
self.live_zone = p.live_zone
# Convert bitmask to set for compatibility with 'idx in p.mulligan_selection'
self.mulligan_selection = {i for i in range(len(p.hand)) if (p.mulligan_selection >> i) & 1}
def __getattr__(self, name):
return getattr(self._p, name)
class RustCompatGameState:
def __init__(self, gs, py_member_db, py_live_db, py_energy_db=None):
self._gs = gs
self.member_db = py_member_db
self.live_db = py_live_db
self.energy_db = py_energy_db
self.current_player = gs.current_player
self.phase = gs.phase
self.turn_number = gs.turn
self.triggered_abilities = []
@property
def pending_choices(self):
# Convert Rust Vec<(String, String)> to [(type, params_dict), ...]
raw = self._gs.pending_choices
result = []
for t, p in raw:
try:
params = json.loads(p)
result.append((t, params))
except:
result.append((t, {}))
return result
@property
def active_player(self):
return RustCompatPlayer(self._gs.get_player(self._gs.current_player))
@property
def inactive_player(self):
return RustCompatPlayer(self._gs.get_player(1 - self._gs.current_player))
@property
def inactive_player_idx(self):
return 1 - self._gs.current_player
@property
def players(self):
return [RustCompatPlayer(self._gs.get_player(0)), RustCompatPlayer(self._gs.get_player(1))]
def get_player(self, idx):
return RustCompatPlayer(self._gs.get_player(idx))
def get_legal_actions(self):
return self._gs.get_legal_actions()
def serialize_card_rust(card_id, db: engine_rust.PyCardDatabase, is_viewable=True):
if card_id < 0:
return None
if not is_viewable:
return {"id": int(card_id), "name": "???", "type": "unknown", "img": "cards/back.png", "hidden": True}
# In Rust engine, card_id is already the index in DB for basic lookups?
# Actually PyCardDatabase needs to expose card data.
# If the Rust PyCardDatabase doesn't expose full card objects yet,
# we might need to load the JSON DB in Python too just for metadata.
# For now, let's assume we use the Python member_db/live_db as a dictionary of metadata
# that matches the IDs in Rust.
pass
class RustGameStateSerializer:
def __init__(self, py_member_db, py_live_db, py_energy_db):
from engine.game.state_utils import MaskedDB
self.member_db = py_member_db if isinstance(py_member_db, MaskedDB) else MaskedDB(py_member_db)
self.live_db = py_live_db if isinstance(py_live_db, MaskedDB) else MaskedDB(py_live_db)
self.energy_db = py_energy_db if isinstance(py_energy_db, MaskedDB) else MaskedDB(py_energy_db)
self._card_cache = {} # Cache for base card metadata
def serialize_card(self, cid, is_viewable=True, peek=False):
if cid < 0:
return None
if not is_viewable and not peek:
return {"id": int(cid), "name": "???", "type": "unknown", "img": "icon_blade.png", "hidden": True}
# Fallback to icon_blade.png for unknown cards if no image path exists
def fix_img(img):
if not img:
return "icon_blade.png"
if img.startswith("assets/"):
return img # energy_card.png
return img
cid_int = int(cid)
base_id = cid_int & 0xFFFFF # Mask with BASE_ID_MASK (20 bits)
if base_id in self._card_cache:
res = self._card_cache[base_id].copy()
res["id"] = cid_int
return res
res = None
# Using the Python DB for metadata (names, images, text)
bid_str = str(base_id)
if bid_str in self.member_db:
m = self.member_db[bid_str]
# Fallback for ability text if not populated
# Prioritize pseudocode (raw_text) for consistent frontend translation
abilities = getattr(m, "abilities", [])
at = "\n".join([getattr(ab, "raw_text", "") for ab in abilities if getattr(ab, "raw_text", "")])
# Fallback to static ability text if no pseudocode available
if not at:
at = getattr(m, "ability_text", "")
res = {
"card_no": m.card_no,
"name": m.name,
"type": "member",
"cost": m.cost,
"blade": m.blades,
"img": m.img_path,
"hearts": list(m.hearts),
"blade_hearts": list(m.blade_hearts),
"text": at,
"original_text": m.original_text,
}
elif bid_str in self.live_db:
l = self.live_db[bid_str]
# Prioritize pseudocode for lives too
abilities = getattr(l, "abilities", [])
at = "\n".join([getattr(ab, "raw_text", "") for ab in abilities if getattr(ab, "raw_text", "")])
if not at:
at = getattr(l, "ability_text", "")
res = {
"card_no": l.card_no,
"name": l.name,
"type": "live",
"score": l.score,
"img": l.img_path,
"required_hearts": list(l.required_hearts),
"text": at,
"original_text": l.original_text,
}
elif bid_str in self.energy_db:
e = self.energy_db[bid_str]
res = {
"card_no": e.card_no,
"name": e.name,
"type": "energy",
"img": e.img_path,
"text": e.ability_text,
"original_text": e.original_text,
}
if res:
self._card_cache[base_id] = res
res_instance = res.copy()
res_instance["id"] = cid_int
return res_instance
return {"id": cid_int, "name": f"Card {base_id}", "type": "unknown", "img": "icon_blade.png"}
def serialize_player(
self, p: engine_rust.PyPlayerState, gs: engine_rust.PyGameState, p_idx, viewer_idx=0, legal_mask=None
):
is_viewable = p_idx == viewer_idx
hand = []
# Use cached legal_mask if provided, otherwise fetch (fallback for direct calls)
if legal_mask is None:
legal_mask = gs.get_legal_actions() if gs.current_player == p_idx else []
elif gs.current_player != p_idx:
legal_mask = [] # Clear mask for non-active player
for i, cid in enumerate(p.hand):
c = self.serialize_card(cid, is_viewable=is_viewable)
if is_viewable:
c["is_new"] = (p.hand_added_turn[i] == gs.turn) if i < len(p.hand_added_turn) else False
valid_actions = []
if len(legal_mask) > 0:
# Mapping logic matching Python serializer
# Play Member: 1 + hand_idx * 3 + slot_idx
for area in range(3):
aid = 1 + i * 3 + area
if aid < len(legal_mask) and legal_mask[aid]:
valid_actions.append(aid)
# Other hand-related actions: Mulligan (300+), LiveSet (400+), SelectHand (500+)
for aid in [300 + i, 400 + i, 500 + i]:
if aid < len(legal_mask) and legal_mask[aid]:
valid_actions.append(aid)
c["valid_actions"] = valid_actions
hand.append(c)
stage = []
rust_stage = p.stage
rust_tapped = p.tapped_members
for i in range(3):
cid = rust_stage[i]
if cid >= 0:
c = self.serialize_card(cid, is_viewable=True)
c["tapped"] = bool(rust_tapped[i])
c["energy"] = int(getattr(p, "stage_energy_count", [0, 0, 0])[i])
c["locked"] = False # Rust doesn't track locked members yet
# Fetch effective stats from Rust
eff_blade = gs.get_effective_blades(p_idx, i)
eff_hearts = gs.get_effective_hearts(p_idx, i)
# Update stats in card dict
c["blade"] = int(eff_blade)
c["hearts"] = [int(h) for h in eff_hearts]
# Calculate modifiers for UI highlighting (Attack +1, etc.)
modifiers = []
base_m = self.member_db.get(int(cid))
if base_m:
if c["blade"] > base_m.blades:
modifiers.append(
{
"type": "blade",
"value": c["blade"] - base_m.blades,
"label": f"Attack +{c['blade'] - base_m.blades}",
}
)
elif c["blade"] < base_m.blades:
modifiers.append(
{
"type": "blade",
"value": c["blade"] - base_m.blades,
"label": f"Attack {c['blade'] - base_m.blades}",
}
)
for j in range(len(c["hearts"])):
if j < len(base_m.hearts) and c["hearts"][j] > base_m.hearts[j]:
modifiers.append(
{"type": "heart", "color_idx": j, "value": c["hearts"][j] - base_m.hearts[j]}
)
c["modifiers"] = modifiers
# Add valid actions for stage highlighting
valid_actions = []
if len(legal_mask) > 0:
# ABILITY is 200 + slot_idx * 10 + ab_idx
for ab_idx in range(10):
aid = 200 + i * 10 + ab_idx
if aid < len(legal_mask) and legal_mask[aid]:
valid_actions.append(aid)
# SELECT_STAGE is 560 + slot_idx
aid = 560 + i
if aid < len(legal_mask) and legal_mask[aid]:
valid_actions.append(aid)
c["valid_actions"] = valid_actions
stage.append(c)
else:
stage.append(None)
# Live Guide Logic
total_hearts = gs.get_total_hearts(p_idx) # [u32; 7]
temp_hearts = list(total_hearts)
live_zone = []
rust_lives = p.live_zone
rust_revealed = p.live_zone_revealed
for i in range(3):
cid = rust_lives[i]
if cid >= 0:
c = self.serialize_card(cid, is_viewable=rust_revealed[i], peek=is_viewable)
# Fulfillment (Rule 8.4.1)
if cid in self.live_db:
l = self.live_db[cid]
req = l.required_hearts
filled = [0] * 7
# Specific
for ci in range(6):
take = min(temp_hearts[ci], req[ci])
filled[ci] = int(take)
temp_hearts[ci] -= take
# Any
req_any = req[6] if len(req) > 6 else 0
rem_total = sum(temp_hearts[:6]) + temp_hearts[6]
take_any = min(rem_total, req_any)
filled[6] = int(take_any)
# Note: We don't decrement from temp_hearts for 'any' matching the Python serializer's logic
c["filled_hearts"] = filled
c["is_cleared"] = all(filled[ci] >= req[ci] for ci in range(6)) and (filled[6] >= req_any)
c["required_hearts"] = list(req)
c["modifiers"] = []
live_zone.append(c)
else:
live_zone.append(None)
energy = []
rust_energy = p.energy_zone
rust_tapped_energy = p.tapped_energy
for i, cid in enumerate(rust_energy):
energy.append(
{"id": i, "tapped": rust_tapped_energy[i], "card": self.serialize_card(cid, is_viewable=False)}
)
# Convert bitmask to list of selected indices for frontend
mulligan_selection_list = [i for i in range(len(p.hand)) if (p.mulligan_selection >> i) & 1]
return {
"player_id": p.player_id,
"score": p.score,
"is_active": (gs.current_player == p_idx),
"hand": hand,
"hand_count": len(hand),
"deck_count": p.deck_count,
"energy_deck_count": p.energy_deck_count,
"discard": [self.serialize_card(cid) for cid in p.discard],
"discard_count": len(p.discard),
"energy": energy,
"energy_count": len(energy),
"energy_untapped": sum(1 for t in rust_tapped_energy if not t),
"live_zone": live_zone,
"live_zone_count": sum(1 for cid in rust_lives if cid >= 0),
"stage": stage,
"success_lives": [self.serialize_card(cid) for cid in p.success_lives],
"restrictions": [],
"total_hearts": [int(h) for h in total_hearts],
"total_blades": int(gs.get_total_blades(p_idx)),
"mulligan_selection": mulligan_selection_list,
"looked_cards": [self.serialize_card(cid) for cid in getattr(p, "looked_cards", [])],
}
def serialize_state(self, gs: engine_rust.PyGameState, viewer_idx=0, mode="pve", is_pvp=False):
# Cache legal_mask once to avoid multiple expensive calls
legal_mask = gs.get_legal_actions()
players = [
self.serialize_player(gs.get_player(0), gs, 0, viewer_idx, legal_mask),
self.serialize_player(gs.get_player(1), gs, 1, viewer_idx, legal_mask),
]
# Action Metadata - reuse cached legal_mask
legal_actions = []
# Compatibility wrapper for get_action_desc
compat_gs = RustCompatGameState(gs, self.member_db, self.live_db, self.energy_db)
# Only show actions if viewer is active (or in PvP/Hotseat which we assume viewer_idx represents)
if viewer_idx == gs.current_player:
for i, v in enumerate(legal_mask):
if v:
desc = get_action_desc(i, compat_gs)
meta = {"id": i, "desc": desc, "name": desc, "description": desc}
# Enrich with metadata for UI highlighting
phase = gs.phase # Assumes phase is exposed as int
if 1 <= i <= 180:
meta["type"] = "PLAY"
meta["hand_idx"] = (i - 1) // 3
meta["area_idx"] = (i - 1) % 3
curr_p = gs.get_player(gs.current_player)
if meta["hand_idx"] < len(curr_p.hand):
cid = curr_p.hand[meta["hand_idx"]]
c = self.serialize_card(cid)
hand_cost = gs.get_member_cost(gs.current_player, cid, -1)
net_cost = gs.get_member_cost(gs.current_player, cid, meta["area_idx"])
meta.update(
{
"img": c["img"],
"name": c["name"],
"cost": int(net_cost),
"base_cost": int(hand_cost),
"text": c.get("text", ""),
"source_card_id": int(cid),
}
)
elif 200 <= i <= 299:
meta["type"] = "ABILITY"
adj = i - 200
meta["area_idx"] = adj // 10
meta["ability_idx"] = adj % 10
curr_p = gs.get_player(gs.current_player)
if meta["area_idx"] < len(curr_p.stage):
cid = curr_p.stage[meta["area_idx"]]
if cid >= 0:
c = self.serialize_card(cid)
# Extract specific ability trigger/text
base_id = int(cid) & 0xFFFFF
triggers = []
raw_text = ""
if base_id in self.member_db:
m = self.member_db[base_id]
if hasattr(m, "abilities") and len(m.abilities) > meta["ability_idx"]:
ab = m.abilities[meta["ability_idx"]]
triggers = [int(ab.trigger)]
raw_text = ab.raw_text
meta.update(
{
"img": c["img"],
"name": desc,
"source_card_id": int(cid),
"triggers": triggers,
"raw_text": raw_text,
"text": "", # Delay ability text
"ability_idx": meta["ability_idx"],
"description": desc,
}
)
elif 300 <= i <= 359:
meta["type"] = "MULLIGAN"
meta["hand_idx"] = i - 300
curr_p = gs.get_player(gs.current_player)
if meta["hand_idx"] < len(curr_p.hand):
cid = curr_p.hand[meta["hand_idx"]]
c = self.serialize_card(cid)
meta.update(
{"img": c["img"], "name": c["name"], "text": c.get("text", ""), "description": desc}
)
elif 400 <= i <= 459:
meta["type"] = "LIVE_SET"
meta["hand_idx"] = i - 400
curr_p = gs.get_player(gs.current_player)
if meta["hand_idx"] < len(curr_p.hand):
cid = curr_p.hand[meta["hand_idx"]]
c = self.serialize_card(cid)
meta.update(
{"img": c["img"], "name": c["name"], "text": c.get("text", ""), "description": desc}
)
elif 100 <= i <= 159 or 500 <= i <= 559:
meta["type"] = "SELECT_HAND"
meta["hand_idx"] = (i - 100) if (100 <= i <= 159) else (i - 500)
curr_p = gs.get_player(gs.current_player)
if meta["hand_idx"] < len(curr_p.hand):
cid = curr_p.hand[meta["hand_idx"]]
c = self.serialize_card(cid)
meta.update(
{"img": c["img"], "name": c["name"], "text": c.get("text", ""), "description": desc}
)
elif 560 <= i <= 562:
meta["type"] = "SELECT_STAGE"
meta["area_idx"] = i - 560
curr_p = gs.get_player(gs.current_player)
cid = curr_p.stage[meta["area_idx"]]
if cid >= 0:
c = self.serialize_card(cid)
meta.update({"img": c["img"], "name": c["name"], "text": "", "description": desc})
# Add pending context for UI grouping
if gs.pending_card_id >= 0:
meta["source_card_id"] = int(gs.pending_card_id)
c = self.serialize_card(gs.pending_card_id)
meta["source_name"] = c["name"]
meta["source_img"] = c["img"]
elif 570 <= i <= 579:
meta["type"] = "SELECT_MODE"
meta["index"] = i - 570
elif 580 <= i <= 585:
meta["type"] = "COLOR_SELECT"
meta["index"] = i - 580
colors = ["Red", "Blue", "Green", "Yellow", "Purple", "Pink"]
if meta["index"] < len(colors):
meta["color"] = colors[meta["index"]]
meta["name"] = f"Color: {colors[meta['index']]}"
meta["description"] = meta["name"]
elif 900 <= i <= 902:
meta["type"] = "SELECT_LIVE"
meta["area_idx"] = i - 900
curr_p = gs.get_player(gs.current_player)
if meta["area_idx"] < len(curr_p.live_zone):
cid = curr_p.live_zone[meta["area_idx"]]
if cid >= 0:
c = self.serialize_card(cid)
meta.update(
{
"img": c["img"],
"name": c["name"],
"source_card_id": int(cid),
"raw_text": c.get("text", ""),
"description": desc,
}
)
elif 590 <= i <= 599:
meta["type"] = "ABILITY_TRIGGER"
elif 550 <= i <= 849:
# Shared range for Ability choices, Card selections, and Opponent targeting
meta["type"] = "ABILITY"
meta["area_idx"] = gs.pending_area_idx
# Enrich based on pending choice context
raw_choices = compat_gs.pending_choices
if raw_choices:
ctype, cparams = raw_choices[0]
# index within the 10-slot block for this ability
choice_idx = (i - 550) % 10
# 1. Selection from a list (e.g. Look at top 3, choose 1)
if ctype in (
"SELECT_FROM_LIST",
"SELECT_SUCCESS_LIVE",
"ORDER_DECK",
"SELECT_FROM_DISCARD",
):
cards = cparams.get("cards", [])
if choice_idx < len(cards):
cid = cards[choice_idx]
c = self.serialize_card(cid)
meta.update(
{
"type": "SELECT",
"img": c["img"],
"name": c["name"],
"source_card_id": int(cid),
}
)
if ctype == "ORDER_DECK":
meta["type"] = "ORDER_DECK"
# 2. Target Opponent Member (600-602)
elif ctype == "TARGET_OPPONENT_MEMBER" and 600 <= i <= 602:
meta["type"] = "TARGET_OPPONENT"
meta["index"] = i - 600
opp = gs.get_player(1 - gs.current_player)
cid = opp.stage[meta["index"]]
if cid >= 0:
c = self.serialize_card(cid)
meta.update({"img": c["img"], "name": c["name"], "source_card_id": int(cid)})
# 3. Fallback: Source card metadata
else:
cid = gs.pending_card_id
if cid >= 0:
c = self.serialize_card(cid)
meta.update(
{"img": c["img"], "name": desc, "text": c.get("text", ""), "description": desc}
)
else:
# Fallback if no pending choice context
cid = gs.pending_card_id
if cid >= 0:
c = self.serialize_card(cid)
meta.update(
{"img": c["img"], "name": desc, "text": c.get("text", ""), "description": desc}
)
elif 2000 <= i <= 2999:
meta["type"] = "ABILITY"
adj = i - 2000
discard_idx = adj // 10
ability_idx = adj % 10
curr_p = gs.get_player(gs.current_player)
if discard_idx < len(curr_p.discard):
cid = curr_p.discard[discard_idx]
c = self.serialize_card(cid)
meta.update(
{
"img": c["img"],
"name": desc,
"source_card_id": int(cid),
"ability_idx": ability_idx,
"description": desc,
"location": "discard",
}
)
elif 1000 <= i <= 1999:
# Range for OnPlay choices (Mode select, slot select context)
# We treat these as PLAY actions so they group with the placement grid
meta["type"] = "PLAY"
adj = i - 1000
meta["hand_idx"] = adj // 100
meta["area_idx"] = (adj % 100) // 10
meta["choice_idx"] = adj % 10
curr_p = gs.get_player(gs.current_player)
if meta["hand_idx"] < len(curr_p.hand):
cid = curr_p.hand[meta["hand_idx"]]
c = self.serialize_card(cid)
# Get costs for UI if applicable
hand_cost = gs.get_member_cost(gs.current_player, cid, -1)
net_cost = gs.get_member_cost(gs.current_player, cid, meta["area_idx"])
meta.update(
{
"img": c["img"],
"name": c["name"],
"cost": int(net_cost),
"base_cost": int(hand_cost),
"text": c.get("text", ""),
"source_card_id": int(cid),
}
)
legal_actions.append(meta)
# Pending Choice Serialization
pending_choice = None
# 1. Try to get explicit pending_choices (Python/Compat engine)
raw_choices = compat_gs.pending_choices
if raw_choices:
choice_type, params = raw_choices[0]
source_name = params.get("source_member", "Ability Root")
source_img = None
source_id = params.get("source_card_id", -1)
if source_id != -1:
c = self.serialize_card(source_id)
source_name = c["name"]
source_img = c["img"]
elif "area" in params:
curr_p = gs.get_player(gs.current_player)
cid = curr_p.stage[params["area"]]
if cid >= 0:
c = self.serialize_card(cid)
source_name = c["name"]
source_img = c["img"]
source_id = int(cid)
pending_choice = {
"type": choice_type,
"description": params.get("effect_description", desc),
"source_ability": params.get("source_ability", ""),
"source_member": source_name,
"source_img": source_img,
"min": params.get("min", 1),
"max": params.get("max", 1),
"can_skip": params.get("can_skip", False),
"params": params,
}
# 2. Fallback: Infer pending choice from legal action ranges (Rust Engine)
elif not raw_choices and any(v for i, v in enumerate(legal_mask) if i >= 500):
# Check ranges in priority order
inferred_type = None
inferred_desc = "Make a selection"
has_select_hand = any(legal_mask[i] for i in range(500, 560))
has_select_stage = any(legal_mask[i] for i in range(560, 570))
has_select_mode = any(legal_mask[i] for i in range(570, 580))
has_select_color = any(legal_mask[i] for i in range(580, 586))
has_ability_trigger = any(legal_mask[i] for i in range(590, 600))
has_target_opp = any(legal_mask[i] for i in range(600, 603)) and (gs.phase == 4 or gs.phase == 8) # MAIN or LIVE_RESULT (usually only MAIN)
has_select_discard = any(legal_mask[i] for i in range(660, 720))
# LIVE_RESULT choices (600+)
has_select_success_live = any(legal_mask[i] for i in range(600, 720)) and gs.phase == 8 # Phase.LIVE_RESULT
# Generic list/mode choices (600+) - catch all if not special
has_generic_choice = any(legal_mask[i] for i in range(600, 720)) and not has_select_success_live and not has_target_opp
# EXCLUDE 1000-1999 from triggering a generic modal if it's a placement choice
# as these are handled in the board grid.
has_select_list = any(legal_mask[i] for i in range(1000, 2000)) and gs.phase != 4 # Phase.MAIN
inferred_params = {}
if has_ability_trigger:
# Triggers are top level, not usually a "choice" modal but a button
pass
elif has_select_color:
inferred_type = "SELECT_COLOR"
inferred_desc = "Select a Color"
elif has_select_mode:
inferred_type = "SELECT_MODE"
inferred_desc = "Select a Mode"
elif has_select_success_live:
inferred_type = "SELECT_SUCCESS_LIVE"
inferred_desc = "็ฒๅพ—ใ™ใ‚‹ใƒฉใ‚คใƒ–ใ‚ซใƒผใƒ‰ใ‚’1ๆžš้ธใ‚“ใงใใ ใ•ใ„"
elif has_target_opp:
inferred_type = "TARGET_OPPONENT_MEMBER"
inferred_desc = "Select Opponent Member"
elif has_generic_choice:
# Catch-all for Rust engine list choices
inferred_type = "SELECT_FROM_LIST"
inferred_desc = "Choose an option"
elif has_select_discard:
inferred_type = "SELECT_FROM_DISCARD"
inferred_desc = "Select from Discard"
curr_p = gs.get_player(gs.current_player)
inferred_params["available_members"] = list(curr_p.discard)
elif has_select_stage:
inferred_type = "SELECT_STAGE"
inferred_desc = "Select a Member"
elif has_select_hand:
inferred_type = "SELECT_FROM_HAND"
inferred_desc = "Select from Hand"
elif has_select_list:
inferred_type = "SELECT_FROM_LIST"
inferred_desc = "Choose an option"
if inferred_type:
# Try to resolve source info from gs.pending_card_id
source_name = "Game"
source_img = None
source_id = int(gs.pending_card_id)
if source_id >= 0:
c = self.serialize_card(source_id)
source_name = c["name"]
source_img = c["img"]
pending_choice = {
"type": inferred_type,
"description": inferred_desc,
"source_member": source_name,
"source_img": source_img,
"source_id": source_id,
"min": 1,
"max": 1,
"can_skip": False,
"params": inferred_params,
}
# 3. New: Support Phase.RESPONSE (Choice postponing)
elif gs.phase == 10:
pending_card_id = gs.pending_card_id
choice_type = gs.pending_choice_type or "PENDING_ABILITY"
choice_desc = "Select an option"
inferred_params = {}
if choice_type == "ORDER_DECK":
choice_desc = "ใƒ‡ใƒƒใ‚ญใฎ้ †็•ชใ‚’้ธใ‚“ใงใใ ใ•ใ„"
# Looking at p[gs.current_player].looked_cards
curr_p = gs.get_player(gs.current_player)
inferred_params["cards"] = list(curr_p.looked_cards)
elif pending_card_id >= 0:
c = self.serialize_card(pending_card_id)
# Infer type from legal actions as fallback
has_color = any(legal_mask[i] for i in range(1000, 2000) if i % 10 == 5) or any(
legal_mask[i] for i in range(550, 850) if i % 10 == 5
)
has_mode = any(legal_mask[i] for i in range(1000, 2000) if i % 10 == 1) or any(
legal_mask[i] for i in range(550, 850) if i % 10 == 1
)
if not gs.pending_choice_type:
if has_color:
choice_type = "SELECT_COLOR"
choice_desc = "้ธๆŠžใ—ใฆใใ ใ•ใ„: ใƒ”ใƒผใ‚นใฎ่‰ฒ"
elif has_mode:
choice_type = "SELECT_MODE"
choice_desc = "้ธๆŠžใ—ใฆใใ ใ•ใ„: ใƒขใƒผใƒ‰"
source_name = c["name"]
source_img = c["img"]
else:
source_name = "Game"
source_img = None
pending_choice = {
"type": choice_type,
"description": choice_desc,
"source_member": source_name,
"source_img": source_img,
"source_id": int(pending_card_id),
"min": 1,
"max": 1,
"can_skip": False,
"params": inferred_params,
}
return {
"turn": gs.turn,
"phase": gs.phase,
"active_player": gs.current_player,
"game_over": gs.is_terminal(),
"winner": gs.get_winner(),
"players": players,
"legal_actions": legal_actions,
"pending_choice": pending_choice,
"rule_log": gs.rule_log,
"performance_results": {int(k): v for k, v in json.loads(gs.last_performance_results).items()}
if gs.phase in (6, 7, 8)
else {},
"last_performance_results": {int(k): v for k, v in json.loads(gs.last_performance_results).items()},
"performance_history": json.loads(gs.performance_history),
"mode": mode,
"is_pvp": is_pvp,
}