diff --git a/cards.py b/cards.py index 2f892f9..3aea7f0 100644 --- a/cards.py +++ b/cards.py @@ -85,7 +85,7 @@ class Citizen(Card): worker_count, gold_payout_on_turn, gold_payout_off_turn, strength_payout_on_turn, strength_payout_off_turn, magic_payout_on_turn, magic_payout_off_turn, has_special_payout_on_turn, has_special_payout_off_turn, special_payout_on_turn, special_payout_off_turn, special_citizen, - expansion): + expansion, is_flipped=False): super().__init__() self.citizen_id = citizen_id self.name = name @@ -108,6 +108,7 @@ class Citizen(Card): self.special_payout_off_turn = special_payout_off_turn self.special_citizen = special_citizen self.expansion = expansion + self.is_flipped = bool(is_flipped) def get_special_payout_on_turn(self): return self.special_payout_on_turn @@ -115,6 +116,7 @@ class Citizen(Card): def to_dict(self): base_dict = super().to_dict() return {**base_dict, + "is_flipped": bool(getattr(self, "is_flipped", False)), "citizen_id": self.citizen_id, "gold_cost": self.gold_cost, "roll_match1": self.roll_match1, @@ -164,12 +166,14 @@ class Citizen(Card): special_payout_on_turn=dict_["special_payout_on_turn"], special_payout_off_turn=dict_["special_payout_off_turn"], special_citizen=dict_["special_citizen"], - expansion=dict_["expansion"]) + expansion=dict_["expansion"], + is_flipped=bool(dict_.get("is_flipped", False))) class Domain(Card): def __init__(self, domain_id, name, gold_cost, shadow_count, holy_count, soldier_count, worker_count, vp_reward, - has_activation_effect, has_passive_effect, passive_effect, activation_effect, text, expansion): + has_activation_effect, has_passive_effect, passive_effect, activation_effect, text, expansion, + acquired_turn_number=None): super().__init__() self.domain_id = domain_id self.name = name @@ -185,6 +189,7 @@ class Domain(Card): self.activation_effect = activation_effect self.text = text self.expansion = expansion + self.acquired_turn_number = acquired_turn_number def to_dict(self): return { @@ -208,7 +213,8 @@ class Domain(Card): "passive_effect": self.passive_effect, "activation_effect": self.activation_effect, "text": self.text, - "expansion": self.expansion + "expansion": self.expansion, + "acquired_turn_number": getattr(self, "acquired_turn_number", None), } @classmethod @@ -227,7 +233,8 @@ class Domain(Card): passive_effect=dict_['passive_effect'], activation_effect=dict_['activation_effect'], text=dict_['text'], - expansion=dict_['expansion'] + expansion=dict_['expansion'], + acquired_turn_number=dict_.get('acquired_turn_number'), ) diff --git a/docs/README_SERVER.md b/docs/README_SERVER.md index 5920260..2937be4 100644 --- a/docs/README_SERVER.md +++ b/docs/README_SERVER.md @@ -54,11 +54,11 @@ The server will start on `http://localhost:8000` ```json { "player_id": "...", - "action_type": "hire_citizen|buy_domain|slay_monster|act_on_required_action|roll_phase|harvest_phase|play_turn", + "action_type": "hire_citizen|build_domain|slay_monster|act_on_required_action|roll_phase|harvest_phase|play_turn", "citizen_id": 123, // for hire_citizen - "domain_id": 456, // for buy_domain + "domain_id": 456, // for build_domain "monster_id": 789, // for slay_monster - "gold_cost": 5, // for hire_citizen/buy_domain + "gold_cost": 5, // for hire_citizen/build_domain "strength_cost": 3, // for slay_monster "magic_cost": 0, // optional "action": "choose 1" // for act_on_required_action diff --git a/docs/game.md b/docs/game.md index 6ea6d99..af1d255 100644 --- a/docs/game.md +++ b/docs/game.md @@ -58,7 +58,7 @@ Common paths: - `roll_phase()` rolls two dice and computes a sum - `harvest_phase()` pays out from owned starters/citizens for all players based on the roll -- `hire_citizen(...)`, `buy_domain(...)`, `slay_monster(...)` mutate board stacks and player resources +- `hire_citizen(...)`, `build_domain(...)`, `slay_monster(...)` mutate board stacks and player resources ### “choose …” actions diff --git a/docs/server.md b/docs/server.md index be5e16e..9754a2a 100644 --- a/docs/server.md +++ b/docs/server.md @@ -45,7 +45,7 @@ Lobby cleanup: Supported `action_type` values currently include: - `hire_citizen` -- `buy_domain` +- `build_domain` - `slay_monster` - `take_resource` - `harvest_card` diff --git a/game.py b/game.py index c594502..56cd117 100644 --- a/game.py +++ b/game.py @@ -1,11 +1,11 @@ import time import random -from json import JSONEncoder -from typing import List -import mariadb from constants import * from cards import * import threading +from game_models import Player, LobbyMember, GameMember +from game_setup import load_game_data +from game_serialization import SummaryEncoder, GameObjectEncoder def _n(x, default=0): @@ -20,7 +20,7 @@ def _validate_hire_or_domain_gold_payment(player, scaled_gold_cost, gp, sp, mp): if gp < 0 or sp < 0 or mp < 0: raise ValueError("Invalid payment (negative amounts).") if sp != 0: - raise ValueError("Strength cannot be spent on hiring citizens or buying domains.") + raise ValueError("Strength cannot be spent on hiring citizens or building domains.") scaled_gold_cost = int(scaled_gold_cost or 0) if scaled_gold_cost > 0 and mp > 0 and gp < 1: raise ValueError("Must pay at least 1 gold to use magic as wild.") @@ -48,6 +48,34 @@ def _citizen_is_thief(citizen): return False +def _parse_domain_effect_kv(effect): + out = {} + for p in (effect or "").split(): + if "=" in p: + k, v = p.split("=", 1) + out[(k or "").strip().lower()] = (v or "").strip() + return out + + +def _parse_resource_kv(spec): + """ + 'g:3' / 'vp:1' / 'm:1' -> (letter, amount) with vp mapped to 'v'. + """ + if not spec or ":" not in spec: + return None, 0 + kind, rest = spec.split(":", 1) + kind = (kind or "").strip().lower() + try: + n = int((rest or "").strip()) + except (TypeError, ValueError): + return None, 0 + if kind == "vp": + kind = "v" + if kind not in ("g", "s", "m", "v"): + return None, 0 + return kind, n + + def _validate_monster_slay_payment(player, strength_cost, magic_min, gp, sp, mp): gp, sp, mp = _n(gp), _n(sp), _n(mp) if gp != 0: @@ -121,8 +149,36 @@ class _ChooseDukeConcurrentHandler: return +class _FlipOneCitizenConcurrentHandler: + """Each pending player chooses one unflipped citizen on their tableau to flip face-down (e.g. Cursed Cavern).""" + + def apply(self, game, player_id, response): + try: + idx = int(str(response).strip()) + except (TypeError, ValueError): + raise ValueError("Invalid citizen choice (send tableau index).") + player = game._player_by_id(player_id) + if not player: + raise ValueError("Player not found.") + oc = list(getattr(player, "owned_citizens", []) or []) + if idx < 0 or idx >= len(oc): + raise ValueError("Invalid citizen index.") + cit = oc[idx] + if getattr(cit, "is_flipped", False): + raise ValueError("That citizen is already flipped.") + game._citizen_set_flipped(cit, True) + game._log_game_event( + f"{game._player_label(player_id)} flipped citizen \"{getattr(cit, 'name', '?')}\" face-down " + f"(Cursed Cavern)." + ) + + def finalize(self, game): + return + + CONCURRENT_HANDLERS = { "choose_duke": _ChooseDukeConcurrentHandler(), + "flip_one_citizen": _FlipOneCitizenConcurrentHandler(), } # Append-only server log included in serialized game state (same for every client). @@ -169,7 +225,7 @@ class Game: self.tick_id = game_state.get('tick_id', 0) self.turn_number = game_state.get('turn_number', 1) self.turn_index = game_state.get('turn_index', 0) - # roll -> roll_pending -> harvest -> action + # roll -> roll_pending -> harvest -> action -> action_end_pending (optional domain cleanup) self.phase = game_state.get('phase', 'roll') self.actions_remaining = game_state.get('actions_remaining', 0) self.harvest_processed = game_state.get('harvest_processed', False) @@ -180,6 +236,7 @@ class Game: self.harvest_consumed = game_state.get('harvest_consumed') or {} self.last_active_time = 0 self.game_log = list(game_state.get('game_log') or []) + self.pending_action_end_queue = list(game_state.get("pending_action_end_queue") or []) # Between roll and harvest we allow a small "finalization window" where effects (or dev rigging) # may legally change the dice. When present, the engine blocks in roll_pending until finalized. self.pending_roll = game_state.get('pending_roll') or None @@ -234,8 +291,9 @@ class Game: # Block only on required player choices (not on standard action prompts) if self.action_required and self.action_required.get("id") and self.action_required.get("id") != self.game_id: - if self.action_required.get("action") == "bonus_resource_choice" or str( - self.action_required.get("action", "")).startswith("choose"): + aa = str(self.action_required.get("action", "") or "") + if self.action_required.get("action") == "bonus_resource_choice" or aa.startswith("choose ") or aa.startswith( + "choose_player") or aa.startswith("choose_monster") or aa == "domain_self_convert": return False if self.phase == "setup": @@ -269,6 +327,31 @@ class Game: # Waiting for the roll to be finalized (possibly changed by an effect / dev rig). return False + if self.phase == 'action_end_pending': + # End-of-action domain prompts (pay/take vs another player). Same blocking rules as + # finishing the action phase with actions_remaining == 0. + aid = self.action_required.get("id") if self.action_required else None + aact = str(self.action_required.get("action", "") or "") if self.action_required else "" + if aid and aid != self.game_id and aact and aact != "standard_action": + return False + if self.pending_action_end_queue: + return False + finisher = self._player_label(self.current_player_id()) + self.turn_index = (self.turn_index + 1) % max(1, len(self.player_list)) + self.turn_number = int(self.turn_number) + 1 + self.phase = 'roll' + self.actions_remaining = 0 + self.action_required["id"] = self.game_id + self.action_required["action"] = "" + self.tick_id += 1 + self._log_game_event(f"{finisher} ended their turn.") + progressed = False + while self.phase in ('roll', 'harvest'): + if not self.advance_tick(): + break + progressed = True + return True or progressed + if self.phase == 'harvest': # Manual harvest: players resolve matching starters/citizens in turn order (active player first). if not getattr(self, "harvest_processed", False): @@ -278,6 +361,11 @@ class Game: self.harvest_consumed = {} self.harvest_player_idx = 0 self.harvest_player_order = self._harvest_player_id_order_starting_active() + # Harvest-phase domain passives (e.g. Jousting Field) must run after deltas are + # cleared for the new harvest round, not during finalize_roll (which ran before + # this reset and would lose passive contributions from harvest_delta tracking). + active = self._player_by_id(self.current_player_id()) + self._apply_harvest_jousting_passive(active) self._harvest_run_automation_until_blocked() # If harvest triggered a required choice, pause progression here. @@ -308,6 +396,10 @@ class Game: self.action_required["id"] = self.current_player_id() self.action_required["action"] = "standard_action" return False + aid = self.action_required.get("id") if self.action_required else None + aact = str(self.action_required.get("action", "") or "") if self.action_required else "" + if aid and aid != self.game_id and aact and aact != "standard_action": + return False finisher = self._player_label(self.current_player_id()) self.turn_index = (self.turn_index + 1) % max(1, len(self.player_list)) self.turn_number = int(self.turn_number) + 1 @@ -337,9 +429,15 @@ class Game: Consume one standard action for the active player. When this drops actions_remaining to 0, the turn is not advanced here: - the caller must apply the hire/buy/slay/take first, then call + the caller must apply the hire/build/slay/take first, then call finish_turn_if_no_actions_remaining() so logs and engine state stay ordered. """ + if self.phase == "action_end_pending": + return False + + if self.is_blocked_on_concurrent_action(): + return False + if self.phase != 'action': # If an action comes in early, fast-forward to action phase. while self.advance_tick(): @@ -348,8 +446,9 @@ class Game: # If we're blocked on a required choice, no standard actions can be taken. if self.action_required and self.action_required.get("id") and self.action_required.get("id") != self.game_id: - if self.action_required.get("action") in ("bonus_resource_choice", "manual_harvest") or str( - self.action_required.get("action", "")).startswith("choose"): + aa = str(self.action_required.get("action", "") or "") + if self.action_required.get("action") in ("bonus_resource_choice", "manual_harvest") or aa.startswith( + "choose ") or aa.startswith("choose_player") or aa.startswith("choose_monster") or aa == "domain_self_convert": return False if player_id != self.current_player_id(): @@ -371,8 +470,18 @@ class Game: def finish_turn_if_no_actions_remaining(self): """After a successful standard action, advance roll/harvest if the turn was just spent.""" - if getattr(self, "phase", None) == "action" and int(getattr(self, "actions_remaining", 0) or 0) == 0: - self.advance_tick() + if getattr(self, "phase", None) != "action" or int(getattr(self, "actions_remaining", 0) or 0) != 0: + return + if self.is_blocked_on_concurrent_action(): + return + ar = getattr(self, "action_required", None) or {} + aid = ar.get("id") + aact = str(ar.get("action", "") or "").strip() + if aid and aid != self.game_id and aact not in ("", "standard_action"): + return + if self._start_action_end_domain_sequence(self.current_player_id()): + return + self.advance_tick() def roll_phase(self): # Roll the RNG dice first (display value). @@ -409,6 +518,13 @@ class Game: fd2 = rd2 if die_two is None else int(die_two) if fd1 < 1 or fd1 > 6 or fd2 < 1 or fd2 > 6: raise ValueError("Final dice must be between 1 and 6") + player = self._player_by_id(player_id) + if not player: + raise ValueError("Player not found") + changed = (fd1 != rd1) or (fd2 != rd2) + if changed: + if not self._apply_roll_modification(player, rd1, rd2, fd1, fd2): + raise ValueError("Illegal roll modification") self.die_one = fd1 self.die_two = fd2 @@ -436,6 +552,177 @@ class Game: f"Turn {int(self.turn_number)} ({who}): roll changed {rd1}+{rd2}={rd1+rd2} -> {fd1}+{fd2}={self.die_sum}." ) + def _owned_citizen_count_for_role_selector(self, player, role_selector): + role = (role_selector or "").strip().lower() + if not role: + return 0 + attr = None + if role == "holy_citizen": + attr = "holy_count" + elif role == "shadow_citizen": + attr = "shadow_count" + elif role == "soldier_citizen": + attr = "soldier_count" + elif role == "worker_citizen": + attr = "worker_count" + if not attr: + return 0 + n = 0 + for c in list(getattr(player, "owned_citizens", []) or []): + if getattr(c, "is_flipped", False): + continue + if int(getattr(c, attr, 0) or 0) > 0: + n += 1 + return n + + def _citizen_set_flipped(self, citizen, flipped): + """Face-down citizens do not harvest pay out and do not count for roll-phase per-role spends.""" + citizen.is_flipped = bool(flipped) + if citizen.is_flipped: + citizen.toggle_visibility(False) + citizen.toggle_accessibility(False) + else: + citizen.toggle_visibility(True) + citizen.toggle_accessibility(True) + + def _begin_concurrent_flip_one_citizen(self, buyer_player_id): + """Start unordered concurrent prompt: each player with ≥1 unflipped citizen picks one to flip.""" + if getattr(self, "concurrent_action", None): + raise ValueError("Another concurrent prompt is already active.") + targets = [] + for p in list(getattr(self, "player_list", []) or []): + oc = list(getattr(p, "owned_citizens", []) or []) + if any(not getattr(c, "is_flipped", False) for c in oc): + targets.append(p.player_id) + if not targets: + self._log_game_event( + f"{self._player_label(buyer_player_id)} played Cursed Cavern — no player had a citizen to flip." + ) + return + self.concurrent_action = _new_concurrent_action( + "flip_one_citizen", + targets, + data={"buyer_id": buyer_player_id, "source": "cursed_cavern"}, + ) + self._log_game_event( + f"{self._player_label(buyer_player_id)} played Cursed Cavern (+4 magic); " + f"each player with citizens must choose one to flip face-down." + ) + + def unflip_citizen(self, player_id, citizen_idx): + """Engine-only: restore one flipped citizen on a player's tableau (not a player-facing action). + + Used for end-of-game scoring or tooling; not exposed on the HTTP API. + """ + player = self._player_by_id(player_id) + if not player: + raise ValueError("Player not found.") + idx = int(_n(citizen_idx)) + oc = list(getattr(player, "owned_citizens", []) or []) + if idx < 0 or idx >= len(oc): + raise ValueError("Invalid citizen index.") + c = oc[idx] + if not getattr(c, "is_flipped", False): + raise ValueError("That citizen is not flipped.") + self._citizen_set_flipped(c, False) + + def unflip_all_citizens_for_final_scoring(self): + """Face-up every flipped tableau citizen before final VP/tie-break tally (engine-only).""" + any_flipped = False + for p in list(getattr(self, "player_list", []) or []): + for c in list(getattr(p, "owned_citizens", []) or []): + if getattr(c, "is_flipped", False): + any_flipped = True + self._citizen_set_flipped(c, False) + if any_flipped: + self._log_game_event("Final scoring: restored all flipped citizens face-up.") + + def _domain_recurring_passive_on_build_turn_cooldown(self, domain): + """Recurring domain passives cannot be used on the turn the domain was purchased.""" + acq = getattr(domain, "acquired_turn_number", None) + if acq is None: + return False + try: + return int(acq) == int(getattr(self, "turn_number", 0) or 0) + except (TypeError, ValueError): + return False + + def _iter_roll_set_one_die_effects(self, player): + for d in list(getattr(player, "owned_domains", []) or []): + if self._domain_recurring_passive_on_build_turn_cooldown(d): + continue + raw = (getattr(d, "passive_effect", None) or "") + effect = str(raw).strip() + if not effect: + continue + parts = effect.split() + head = parts[0].strip().lower() + if head != "roll.set_one_die": + continue + kv = {} + for p in parts[1:]: + if "=" not in p: + continue + k, v = p.split("=", 1) + kv[(k or "").strip().lower()] = (v or "").strip() + try: + target = int(kv.get("target", "")) + except (TypeError, ValueError): + continue + if target < 1 or target > 6: + continue + cost_spec = kv.get("cost", "") + if not cost_spec: + continue + yield {"domain_name": getattr(d, "name", "Domain"), "target": target, "cost_spec": cost_spec} + + def _resolve_roll_effect_cost(self, player, cost_spec): + spec = (cost_spec or "").strip().lower() + # g:N + if spec.startswith("g:"): + try: + n = int(spec.split(":", 1)[1]) + except (TypeError, ValueError): + return None + if n < 0: + return None + return {"gold": n} + # g_per_owned_role:holy_citizen + if spec.startswith("g_per_owned_role:"): + role = spec.split(":", 1)[1].strip() + n = self._owned_citizen_count_for_role_selector(player, role) + return {"gold": n} + if spec in ("g:per_owned_holy_citizen", "per_owned_holy_citizen"): + n = self._owned_citizen_count_for_role_selector(player, "holy_citizen") + return {"gold": n} + return None + + def _apply_roll_modification(self, player, rd1, rd2, fd1, fd2): + changed1 = (fd1 != rd1) + changed2 = (fd2 != rd2) + if changed1 == changed2: + return False + new_value = fd1 if changed1 else fd2 + for effect in self._iter_roll_set_one_die_effects(player): + if int(effect.get("target", 0) or 0) != int(new_value): + continue + cost = self._resolve_roll_effect_cost(player, effect.get("cost_spec")) + if not cost: + continue + g = int(cost.get("gold", 0) or 0) + if int(getattr(player, "gold_score", 0) or 0) < g: + continue + before = self._player_scores_line(player) + if g: + player.gold_score = int(player.gold_score) - g + after = self._player_scores_line(player) + self._log_game_event( + f"{self._player_label(player.player_id)} used {effect.get('domain_name')} " + f"(pay {g} gold) during roll: scores {before} -> {after}" + ) + return True + return False + def _player_by_id(self, player_id): for p in self.player_list: if p.player_id == player_id: @@ -630,6 +917,8 @@ class Game: "_obj": st, }) for idx, cit in enumerate(getattr(player, "owned_citizens", []) or []): + if getattr(cit, "is_flipped", False): + continue ok, n = self._roll_match_count(cit) if not ok: continue @@ -660,6 +949,8 @@ class Game: def _player_has_unharvested_thief_citizen(self, player, consumed_keys): consumed = set(consumed_keys or []) for idx, cit in enumerate(getattr(player, "owned_citizens", []) or []): + if getattr(cit, "is_flipped", False): + continue if not _citizen_is_thief(cit): continue ok, n = self._roll_match_count(cit) @@ -681,7 +972,9 @@ class Game: aa = self.action_required.get("action") or "" if aa in ("bonus_resource_choice", "manual_harvest"): return True - if str(aa).startswith("choose"): + if str(aa).startswith("choose ") or str(aa).startswith("choose_player") or str(aa).startswith("choose_monster"): + return True + if str(aa) == "domain_self_convert": return True return False @@ -724,14 +1017,9 @@ class Game: if not slots: self.harvest_player_idx += 1 continue - if len(slots) >= 2: - self.action_required["id"] = pid - self.action_required["action"] = "manual_harvest" - self._log_game_event( - f"{self._player_label(pid)}: choose harvest order ({len(slots)} matching cards)." - ) - return - slot = slots[0] + # Auto-resolve harvest order using the deterministic simulation order. + # Only true blocking choices/special prompts should pause harvest. + slot = self._harvest_slots_sorted_for_simulation(slots)[0] self._apply_harvest_activation(player, slot["_obj"], slot["kind"], on_turn) consumed_list.append(slot["slot_key"]) if self._harvest_action_blocked(): @@ -807,6 +1095,10 @@ class Game: def harvest_phase(self): """Resolve the entire harvest non-interactively (local scripts / play_turn).""" + for p in self.player_list: + p.harvest_delta = {"gold": 0, "strength": 0, "magic": 0, "victory": 0} + active = self._player_by_id(self.current_player_id()) + self._apply_harvest_jousting_passive(active) order = self._harvest_player_id_order_starting_active() for pid in order: player = self._player_by_id(pid) @@ -836,8 +1128,64 @@ class Game: return self._harvest_run_automation_until_blocked() - def execute_special_payout(self, command, player_id): + def _execute_compound_payout(self, compound_command, player_id, auto_apply_single_choice=True): + """ + Execute multiple commands separated by +. + e.g. "s 3 + choose " + Non-choice commands are executed immediately and return [result]. + Choice commands set action_required and return [0,0,0,0]. + """ + parts = [p.strip() for p in (compound_command or "").split(" + ")] + if not parts: + return [0, 0, 0, 0] + total_payout = [0, 0, 0, 0] + player = self._player_by_id(player_id) + if not player: + return [-9999, 0, 0, 0] + prior_action = (self.action_required or {}).get("action", "") + prior_concurrent = getattr(self, "concurrent_action", None) + for cmd in parts: + if not cmd: + continue + payout = self.execute_special_payout( + cmd, + player_id, + auto_apply_single_choice=auto_apply_single_choice, + ) + new_action = (self.action_required or {}).get("action", "") + new_concurrent = getattr(self, "concurrent_action", None) + if (new_action and new_action != prior_action) or (new_concurrent is not prior_concurrent): + return total_payout + if isinstance(payout, list) and len(payout) >= 4: + if payout[0] == -9999: + prior_empty = ( + total_payout[0] == 0 and total_payout[1] == 0 + and total_payout[2] == 0 and total_payout[3] == 0 + ) + if prior_empty: + return payout + return total_payout + total_payout[0] += payout[0] + total_payout[1] += payout[1] + total_payout[2] += payout[2] + total_payout[3] += payout[3] + return total_payout + + def execute_special_payout(self, command, player_id, auto_apply_single_choice=True): print("executing special payout") + raw = (command or "").strip() + low = raw.lower() + if low.startswith("manipulate_resources"): + return self._execute_manipulate_resources_self_convert_payout(raw, player_id) + if low == "concurrent_flip_one_citizen": + self._begin_concurrent_flip_one_citizen(player_id) + return [0, 0, 0, 0] + if " + " in raw and not raw.startswith("choose"): + return self._execute_compound_payout( + raw, + player_id, + auto_apply_single_choice=auto_apply_single_choice, + ) payout = [0, 0, 0, 0] # gp, sp, mp, vp, todo: citizen, monster, domain split_command = (command or "").split() if not split_command: @@ -849,9 +1197,24 @@ class Game: second_word = split_command[1] third_word = split_command[2] fourth_word = split_command[3] + if first_word in ("g", "s", "m", "v"): + try: + amount = int(second_word) + if first_word == "g": + payout[0] = amount + elif first_word == "s": + payout[1] = amount + elif first_word == "m": + payout[2] = amount + elif first_word == "v": + payout[3] = amount + print(payout) + return payout + except (TypeError, ValueError): + payout[0] = -9999 + return payout match first_word: case "count": - print("Matched count") match second_word: case "owned_shadow": self.update_payout_for_role('shadow_count', player_id, payout, split_command) @@ -880,23 +1243,9 @@ class Game: payout[3] = area_count * int(split_command[4]) case _: payout[0] = -9999 - case "type": - type_count = self.owned_monster_attributes(player_id)[third_word] - match fourth_word: - case 'g': - payout[0] = type_count * int(split_command[4]) - case 's': - payout[1] = type_count * int(split_command[4]) - case 'm': - payout[2] = type_count * int(split_command[4]) - case 'v': - payout[3] = type_count * int(split_command[4]) - case _: - payout[0] = -9999 case _: payout[0] = -9999 case "exchange": - print("Matched exchange") match second_word: case 'g': payout[0] = payout[0] - int(third_word) @@ -920,21 +1269,27 @@ class Game: case _: payout[0] = -9999 case "choose": - # "choose ..." is a blocking prompt: no immediate payout is applied here. - # It is resolved later via act_on_required_action(), which applies the - # chosen payout and then resumes harvest automation (if active). normalized, options = self._normalize_choose_command(command) + options = self._filter_unavailable_choose_options(options) if not options: payout[0] = -9999 return payout + prompt_options = self._expand_choose_options_for_prompt(options) + if not prompt_options: + payout[0] = -9999 + return payout + if auto_apply_single_choice and len(prompt_options) == 1: + ok = self._apply_choose_option(player_id, prompt_options[0]) + if not ok: + payout[0] = -9999 + return payout self.action_required["id"] = player_id self.action_required["action"] = normalized - # Keep a small bit of context for debugging / future extensions. self.pending_required_choice = { "kind": "special_payout_choose", "player_id": player_id, "command": normalized, - "options": options, + "options": prompt_options, } case _: payout[0] = -9999 @@ -945,41 +1300,886 @@ class Game: """ Normalize a "choose" special payout into a canonical string + parsed options. - Supported input formats (1-3 options): + Supported input formats: - "choose g 2 m 2" - - "choose g 1 s 1 m 1" + - "choose g 3 " + - "choose " + - "choose " Returns: - (normalized_command: str, options: list[dict{token, amount}]) """ - parts = (command or "").strip().split() - if not parts or parts[0].lower() != "choose": + raw = (command or "").strip() + if not raw.lower().startswith("choose"): return (command or ""), [] - rest = parts[1:] + rest = raw[6:].strip() options = [] - - def add_opt(tok, amt): - t = (tok or "").strip().lower() - if t not in ("g", "s", "m", "v"): - return - try: - n = int(amt) - except (TypeError, ValueError): - return - options.append({"token": t, "amount": n}) - - # Strict: pairs token, amount (g 2 m 2 ...) i = 0 - while i + 1 < len(rest) and len(options) < 3: - a, b = rest[i], rest[i + 1] - if (a or "").lower() in ("g", "s", "m", "v"): - add_opt(a, b) - i += 2 + n = len(rest) + while i < n: + while i < n and rest[i].isspace(): + i += 1 + if i >= n: + break + if rest[i] == "<": + j = rest.find(">", i + 1) + if j == -1: + return (command or ""), [] + inner = rest[i + 1:j].strip() + parsed_inner = self._parse_choose_inner_option(inner) + if parsed_inner is None: + return (command or ""), [] + options.append(parsed_inner) + i = j + 1 continue - break - - normalized = "choose " + " ".join([f"{o['token']} {o['amount']}" for o in options]) if options else (command or "") + j = i + while j < n and not rest[j].isspace(): + j += 1 + tok = rest[i:j].strip().lower() + i = j + while i < n and rest[i].isspace(): + i += 1 + k = i + while k < n and not rest[k].isspace(): + k += 1 + amt_s = rest[i:k].strip() + try: + amt = int(amt_s) + except (TypeError, ValueError): + return (command or ""), [] + if tok not in ("g", "s", "m", "v") or amt <= 0: + return (command or ""), [] + options.append({"token": tok, "amount": amt}) + i = k + if not options: + return (command or ""), [] + norm_parts = [] + for o in options: + if o["token"] in ("g", "s", "m", "v"): + norm_parts.append(f"{o['token']} {o['amount']}") + elif o["token"] == "count_area": + norm_parts.append(f"") + elif o["token"] == "citizens_where": + spec = o.get("spec", {}) + extras = o.get("extras") or [] + extra_str = "" + if extras: + extra_str = " + " + " + ".join([f"{e['token']} {e['amount']}" for e in extras]) + if spec.get("is_any"): + norm_parts.append(f"") + elif spec.get("clauses"): + clause_parts = [] + for clause in list(spec.get("clauses") or []): + clause_parts.append( + f"{clause.get('field')} {clause.get('op')} {clause.get('value')}" + ) + norm_parts.append(f"") + else: + norm_parts.append( + f"" + ) + else: + return (command or ""), [] + normalized = "choose " + " ".join(norm_parts) return normalized, options + def _parse_choose_inner_option(self, inner): + s = (inner or "").strip() + if not s: + return None + parts = s.split() + if len(parts) >= 5 and parts[0].lower() == "count" and parts[1].lower() == "area": + area = parts[2] + resource = parts[3].lower() + try: + mult = int(parts[4]) + except (TypeError, ValueError): + return None + if mult <= 0 or resource not in ("g", "s", "m", "v"): + return None + if area not in Constants.areas: + return None + return {"token": "count_area", "area": area, "resource": resource, "mult": mult, "amount": 1} + return self._parse_citizens_inner_option(s) + + def _parse_citizens_inner_option(self, inner): + clauses = [c.strip() for c in (inner or "").split("+")] + if not clauses: + return None + base = clauses[0] + spec = self._parse_boutique_citizen_where(base) + if spec is None: + return None + extras = [] + for c in clauses[1:]: + p = c.split() + if len(p) != 2: + return None + tok = p[0].strip().lower() + try: + amt = int(p[1]) + except (TypeError, ValueError): + return None + if tok not in ("g", "s", "m", "v") or amt <= 0: + return None + extras.append({"token": tok, "amount": amt}) + return {"token": "citizens_where", "amount": 1, "spec": spec, "extras": extras} + + def _parse_boutique_citizen_where(self, inner): + parts = (inner or "").strip().split() + if len(parts) == 1 and parts[0].lower() == "citizens": + return {"pool": "citizens", "field": "gold_cost", "op": ">=", "value": "0", "is_any": True} + if len(parts) < 3: + return None + if parts[0].lower() != "citizens" or parts[1].lower() != "where": + return None + predicate = " ".join(parts[2:]).strip() + clauses = [c.strip() for c in predicate.split(" and ") if c.strip()] + if not clauses: + return None + parsed_clauses = [] + for clause in clauses: + field = op = value = None + for candidate_op in ("<=", ">=", "==", "=", "<", ">"): + if candidate_op in clause: + left, right = clause.split(candidate_op, 1) + field = (left or "").strip().lower() + op = "==" if candidate_op == "=" else candidate_op + value = (right or "").strip() + break + if not field or not op or value == "": + return None + if field not in ("gold_cost", "name", "shadow_count", "holy_count", "soldier_count", "worker_count", "role"): + return None + if field == "name": + if op != "==": + return None + parsed_clauses.append({"field": field, "op": op, "value": value}) + elif field == "role": + if op != "==": + return None + if value.lower() not in ("shadow", "holy", "soldier", "worker"): + return None + parsed_clauses.append({"field": field, "op": op, "value": value.lower()}) + else: + try: + int(value) + except (TypeError, ValueError): + return None + parsed_clauses.append({"field": field, "op": op, "value": value}) + return {"pool": "citizens", "clauses": parsed_clauses, "is_any": False} + + def _citizen_matches_clause(self, citizen, clause): + field = (clause.get("field") or "").strip().lower() + op = (clause.get("op") or "").strip() + value = clause.get("value") + if field == "name": + if op != "==": + return False + card_name = (getattr(citizen, "name", "") or "").strip().lower() + cmp_name = (value or "").strip().lower().strip("\"'") + return card_name == cmp_name + if field == "role": + role = (value or "").strip().lower() + if role == "shadow": + return int(getattr(citizen, "shadow_count", 0) or 0) > 0 + if role == "holy": + return int(getattr(citizen, "holy_count", 0) or 0) > 0 + if role == "soldier": + return int(getattr(citizen, "soldier_count", 0) or 0) > 0 + if role == "worker": + return int(getattr(citizen, "worker_count", 0) or 0) > 0 + return False + try: + card_v = int(getattr(citizen, field, 0) or 0) + cmp_v = int(value) + except (TypeError, ValueError): + return False + if op == "==": + return card_v == cmp_v + if op == "<=": + return card_v <= cmp_v + if op == ">=": + return card_v >= cmp_v + if op == "<": + return card_v < cmp_v + if op == ">": + return card_v > cmp_v + return False + + def _citizen_matches_filter(self, citizen, spec): + if not isinstance(spec, dict): + return False + if spec.get("is_any"): + return True + clauses = spec.get("clauses") + if clauses: + return all(self._citizen_matches_clause(citizen, clause) for clause in clauses) + field = (spec.get("field") or "").strip().lower() + op = (spec.get("op") or "").strip() + value = spec.get("value") + if field == "name": + card_name = (getattr(citizen, "name", "") or "").strip().lower() + cmp_name = (value or "").strip().lower().strip("\"'") + if op != "==": + return False + return card_name == cmp_name + try: + card_v = int(getattr(citizen, field, 0) or 0) + cmp_v = int(value) + except (TypeError, ValueError): + return False + if op == "==": + return card_v == cmp_v + if op == "<=": + return card_v <= cmp_v + if op == ">=": + return card_v >= cmp_v + if op == "<": + return card_v < cmp_v + if op == ">": + return card_v > cmp_v + return False + + def _board_citizen_candidates(self, spec): + out = [] + for stack in self.citizen_grid: + if not stack: + continue + top = stack[-1] + if not getattr(top, "is_accessible", False): + continue + if self._citizen_matches_filter(top, spec): + out.append(top) + return out + + def _player_can_afford_self_convert_resources(self, player, pay_k, pay_n): + if pay_k == "g": + return int(getattr(player, "gold_score", 0) or 0) >= pay_n + if pay_k == "s": + return int(getattr(player, "strength_score", 0) or 0) >= pay_n + if pay_k == "m": + return int(getattr(player, "magic_score", 0) or 0) >= pay_n + if pay_k == "v": + return int(getattr(player, "victory_score", 0) or 0) >= pay_n + return False + + def _apply_self_convert_kv_to_player(self, player, kv): + pay_k, pay_n = _parse_resource_kv(kv.get("pay", "")) + gain_k, gain_n = _parse_resource_kv(kv.get("gain", "")) + idx = {"g": 0, "s": 1, "m": 2, "v": 3} + pi, gi = idx[pay_k], idx[gain_k] + payout = [0, 0, 0, 0] + payout[pi] -= pay_n + payout[gi] += gain_n + player.gold_score = int(player.gold_score) + payout[0] + player.strength_score = int(player.strength_score) + payout[1] + player.magic_score = int(player.magic_score) + payout[2] + player.victory_score = int(getattr(player, "victory_score", 0)) + payout[3] + self._bump_harvest_delta(player, payout[0], payout[1], payout[2], payout[3]) + + def _resume_after_domain_activation_follow_up(self): + """Clear optional domain activation prompts and restore action/end-turn resolution.""" + self.pending_required_choice = None + if getattr(self, "phase", None) == "action" and int(getattr(self, "actions_remaining", 0) or 0) > 0: + self.action_required["id"] = self.current_player_id() + self.action_required["action"] = "standard_action" + return + self.action_required["id"] = self.game_id + self.action_required["action"] = "" + if getattr(self, "phase", None) == "action" and int(getattr(self, "actions_remaining", 0) or 0) == 0: + if self._start_action_end_domain_sequence(self.current_player_id()): + return + + def _prompt_or_apply_self_convert(self, raw, player, domain=None): + """ + Activation self_convert: optional effects prompt confirm/decline when affordable. + Non-optional applies immediately when affordable. + """ + payout = [0, 0, 0, 0] + kv = _parse_domain_effect_kv(raw) + if (kv.get("mode") or "").strip().lower() != "self_convert": + payout[0] = -9999 + return payout + optional = str(kv.get("optional", "")).strip().lower() in ("true", "1", "yes") + pay_k, pay_n = _parse_resource_kv(kv.get("pay", "")) + gain_k, gain_n = _parse_resource_kv(kv.get("gain", "")) + if not player or not pay_k or not gain_k or pay_n <= 0 or gain_n <= 0: + payout[0] = -9999 + return payout + can_pay = self._player_can_afford_self_convert_resources(player, pay_k, pay_n) + if optional: + if not can_pay: + return [0, 0, 0, 0] + domain_name = "Domain" + if domain is not None: + domain_name = getattr(domain, "name", None) or domain_name + self.action_required["id"] = player.player_id + self.action_required["action"] = "domain_self_convert" + self.pending_required_choice = { + "kind": "domain_self_convert", + "player_id": player.player_id, + "kv": kv, + "domain_name": domain_name, + } + return [0, 0, 0, 0] + if not can_pay: + return [-9999, 0, 0, 0] + idx = {"g": 0, "s": 1, "m": 2, "v": 3} + pi, gi = idx[pay_k], idx[gain_k] + payout[pi] -= pay_n + payout[gi] += gain_n + return payout + + def _execute_manipulate_resources_self_convert_payout(self, raw, player_id): + """Activation / compound payout fragment: bank trade (e.g. Wisborg).""" + player = self._player_by_id(player_id) + if not player: + return [-9999, 0, 0, 0] + return self._prompt_or_apply_self_convert(raw, player, None) + + def _player_resource_tuple(self, player): + return ( + int(getattr(player, "gold_score", 0) or 0), + int(getattr(player, "strength_score", 0) or 0), + int(getattr(player, "magic_score", 0) or 0), + int(getattr(player, "victory_score", 0) or 0), + ) + + def _transfer_resources_player_to_player(self, from_player, to_player, dg, ds, dm, dv): + fg, fs, fm, fv = self._player_resource_tuple(from_player) + if dg > fg or ds > fs or dm > fm or dv > fv: + return False + from_player.gold_score = fg - dg + from_player.strength_score = fs - ds + from_player.magic_score = fm - dm + from_player.victory_score = fv - dv + tg, ts, tm, tv = self._player_resource_tuple(to_player) + to_player.gold_score = tg + dg + to_player.strength_score = ts + ds + to_player.magic_score = tm + dm + to_player.victory_score = tv + dv + return True + + def _bank_gain_for_active(self, player, gain_k, gain_n): + if gain_k == "g": + player.gold_score = int(player.gold_score) + gain_n + self._bump_harvest_delta(player, gain_n, 0, 0, 0) + elif gain_k == "s": + player.strength_score = int(player.strength_score) + gain_n + self._bump_harvest_delta(player, 0, gain_n, 0, 0) + elif gain_k == "m": + player.magic_score = int(player.magic_score) + gain_n + self._bump_harvest_delta(player, 0, 0, gain_n, 0) + elif gain_k == "v": + player.victory_score = int(getattr(player, "victory_score", 0)) + gain_n + self._bump_harvest_delta(player, 0, 0, 0, gain_n) + + def _parse_manipulate_action_end(self, passive_text): + s = (passive_text or "").strip() + low = s.lower() + if not low.startswith("action.end"): + return None + rest = s[len("action.end"):].strip() + if not rest.lower().startswith("manipulate_resources"): + return None + return _parse_domain_effect_kv(rest) + + def _collect_action_end_manipulate_queue(self, active_player): + out = [] + for d in list(getattr(active_player, "owned_domains", []) or []): + if self._domain_recurring_passive_on_build_turn_cooldown(d): + continue + kv = self._parse_manipulate_action_end(getattr(d, "passive_effect", None) or "") + if not kv: + continue + mode = (kv.get("mode") or "").strip().lower() + if mode not in ("take_from_player", "pay_to_player"): + continue + out.append({ + "domain_name": getattr(d, "name", "Domain"), + "mode": mode, + "kv": kv, + }) + return out + + def _manipulate_candidates_other_players(self, active_pid, take_or_pay, kv): + """ + take_or_pay: 'take' (active receives from victim) or 'pay' (active pays victim, optional bank gain). + """ + pay_k, pay_n = _parse_resource_kv(kv.get("pay", "")) + take_k, take_n = _parse_resource_kv(kv.get("take", "")) + gain_k, gain_n = _parse_resource_kv(kv.get("gain", "")) + optional = str(kv.get("optional", "")).strip().lower() in ("true", "1", "yes") + res_k, res_n = (take_k, take_n) if take_or_pay == "take" else (pay_k, pay_n) + if not res_k or res_n <= 0: + return None, optional + idx = {"g": 0, "s": 1, "m": 2, "v": 3} + ri = idx[res_k] + opts = [] + for p in self.player_list: + if p.player_id == active_pid: + continue + tup = self._player_resource_tuple(p) + if take_or_pay == "take" and tup[ri] < res_n: + continue + if take_or_pay == "pay": + opts.append({"token": "player", "player_id": p.player_id, "name": getattr(p, "name", "?")}) + continue + opts.append({"token": "player", "player_id": p.player_id, "name": getattr(p, "name", "?")}) + return {"res_k": res_k, "res_n": res_n, "gain_k": gain_k, "gain_n": gain_n, "mode": kv.get("mode"), "options": opts}, optional + + def _start_action_end_domain_sequence(self, active_pid): + active = self._player_by_id(active_pid) + if not active: + return False + q = self._collect_action_end_manipulate_queue(active) + self.pending_action_end_queue = q + if not q: + return False + self.phase = "action_end_pending" + blocked = self._drain_action_end_manipulate_queue() + if not blocked: + self.phase = "action" + return blocked + + def _drain_action_end_manipulate_queue(self): + while self.pending_action_end_queue: + item = self.pending_action_end_queue[0] + active_pid = self.current_player_id() + active = self._player_by_id(active_pid) + if not active: + self.pending_action_end_queue = [] + return False + mode = item["mode"] + kv = item["kv"] + gain_k, gain_n_from_kv = _parse_resource_kv(kv.get("gain", "")) + optional = str(kv.get("optional", "")).strip().lower() in ("true", "1", "yes") + vp_pay_may_decline = mode == "pay_to_player" and gain_k == "v" and gain_n_from_kv > 0 + optional_effective = optional or vp_pay_may_decline + take_or_pay = "take" if mode == "take_from_player" else "pay" + parsed, _opt = self._manipulate_candidates_other_players(active_pid, take_or_pay, kv) + if not parsed or not parsed.get("options"): + self.pending_action_end_queue.pop(0) + if optional_effective: + continue + self.pending_action_end_queue = [] + return False + gain_k, gain_n = parsed.get("gain_k"), int(parsed.get("gain_n") or 0) + res_k, res_n = parsed.get("res_k"), int(parsed.get("res_n") or 0) + opts = parsed["options"] + if mode == "pay_to_player": + ap = self._player_by_id(active_pid) + pk, pn = _parse_resource_kv(kv.get("pay", "")) + if not pk or pn <= 0 or int(self._player_resource_tuple(ap)[{"g": 0, "s": 1, "m": 2, "v": 3}[pk]]) < pn: + self.pending_action_end_queue.pop(0) + if optional_effective: + continue + self.pending_action_end_queue = [] + return False + self.action_required["id"] = active_pid + self.action_required["action"] = "choose_player" + self.pending_required_choice = { + "kind": "domain_manipulate_player", + "player_id": active_pid, + "item": item, + "options": opts, + "allow_skip": optional_effective, + } + return True + return False + + def _apply_manipulate_player_choice(self, active_pid, target_pid, item): + active = self._player_by_id(active_pid) + victim = self._player_by_id(target_pid) + if not active or not victim: + return + mode = item["mode"] + kv = item["kv"] + before_a = self._player_scores_line(active) + before_v = self._player_scores_line(victim) + gain_k, gain_n = _parse_resource_kv(kv.get("gain", "")) + if mode == "take_from_player": + tk, tn = _parse_resource_kv(kv.get("take", "")) + dg = ds = dm = dv = 0 + if tk == "g": + dg = tn + elif tk == "s": + ds = tn + elif tk == "m": + dm = tn + elif tk == "v": + dv = tn + if not self._transfer_resources_player_to_player(victim, active, dg, ds, dm, dv): + return + elif mode == "pay_to_player": + pk, pn = _parse_resource_kv(kv.get("pay", "")) + dg = ds = dm = dv = 0 + if pk == "g": + dg = pn + elif pk == "s": + ds = pn + elif pk == "m": + dm = pn + elif pk == "v": + dv = pn + if not self._transfer_resources_player_to_player(active, victim, dg, ds, dm, dv): + return + if gain_k and gain_n > 0: + self._bank_gain_for_active(active, gain_k, gain_n) + after_a = self._player_scores_line(active) + after_v = self._player_scores_line(victim) + bank_vp_note = "" + if mode == "pay_to_player" and gain_k == "v" and gain_n > 0: + bank_vp_note = f" (+{gain_n} VP from bank, not from target)" + self._log_game_event( + f"{self._player_label(active_pid)} end-of-action \"{item.get('domain_name')}\" vs " + f"{self._player_label(target_pid)}: active {before_a} -> {after_a}; target {before_v} -> {after_v}" + f"{bank_vp_note}" + ) + + def _apply_harvest_jousting_passive(self, player): + """Apply automatic harvest-phase domain passives for the active player. + + Accepts DB spellings `harvest.gain_per_owned_citizen_name` and `harvest:gain_per_owned_citizen_name`. + Format: ` ` + resource_letter: g | s | m | v + """ + if not player: + return + for d in list(getattr(player, "owned_domains", []) or []): + if self._domain_recurring_passive_on_build_turn_cooldown(d): + continue + raw = (getattr(d, "passive_effect", None) or "").strip() + if not raw: + continue + parts = raw.split() + verb = parts[0].strip().lower() + if verb != "harvest.gain_per_owned_citizen_name": + continue + if len(parts) < 4: + continue + citizen_name = parts[1] + res = (parts[2] or "").strip().lower() + try: + mult = int(parts[3]) + except (TypeError, ValueError): + continue + want = citizen_name.strip().lower() + n = 0 + for c in list(getattr(player, "owned_citizens", []) or []): + if getattr(c, "is_flipped", False): + continue + if (getattr(c, "name", "") or "").strip().lower() == want: + n += 1 + if n <= 0: + continue + gain = mult * n + dg = ds = dm = dv = 0 + if res == "g": + dg = gain + elif res == "s": + ds = gain + elif res == "m": + dm = gain + elif res == "v": + dv = gain + else: + continue + before = self._player_scores_line(player) + player.gold_score = int(player.gold_score) + dg + player.strength_score = int(player.strength_score) + ds + player.magic_score = int(player.magic_score) + dm + player.victory_score = int(player.victory_score) + dv + self._bump_harvest_delta(player, dg, ds, dm, dv) + after = self._player_scores_line(player) + self._log_game_event( + f"{self._player_label(player.player_id)} harvest passive \"{getattr(d, 'name', 'Domain')}\" " + f"({citizen_name} x{n}): scores {before} -> {after}" + ) + + def _prompt_domain_monster_strength_boost(self, player, domain, effect): + parts = effect.split() + delta = 3 + if parts: + try: + delta = int(str(parts[-1]).replace("+", "").strip()) + except (TypeError, ValueError): + delta = 3 + options = [] + for stack in self.monster_grid: + if not stack: + continue + top = stack[-1] + if not getattr(top, "is_accessible", False): + continue + options.append({ + "token": "monster.choice", + "monster_id": int(getattr(top, "monster_id", -1)), + "name": getattr(top, "name", "?"), + }) + if not options: + self._log_game_event( + f"{self._player_label(player.player_id)} could not use \"{getattr(domain, 'name', 'Domain')}\" " + f"(no accessible monsters)." + ) + return + if len(options) == 1: + self._apply_monster_strength_boost(options[0]["monster_id"], delta) + self._log_game_event( + f"{self._player_label(player.player_id)} activated \"{getattr(domain, 'name', 'Domain')}\" " + f"on \"{options[0].get('name')}\" (+{delta} strength cost)." + ) + return + self.action_required["id"] = player.player_id + self.action_required["action"] = "choose_monster_strength" + self.pending_required_choice = { + "kind": "domain_boost_monster", + "player_id": player.player_id, + "delta": delta, + "domain_name": getattr(domain, "name", "Domain"), + "options": options, + } + + def _apply_monster_strength_boost(self, monster_id, delta): + try: + mid = int(monster_id) + except (TypeError, ValueError): + return False + for stack in self.monster_grid: + if not stack: + continue + top = stack[-1] + if int(getattr(top, "monster_id", -1)) != mid: + continue + if not getattr(top, "is_accessible", False): + return False + top.strength_cost = int(getattr(top, "strength_cost", 0) or 0) + int(delta or 0) + return True + return False + + def _apply_domain_activation_effect(self, player, domain): + effect = (getattr(domain, "activation_effect", None) or "").strip() + if not effect: + return + low = effect.lower() + if low.startswith("action.modify_monster_strength"): + self._prompt_domain_monster_strength_boost(player, domain, effect) + return + if low.startswith("manipulate_resources"): + kv = _parse_domain_effect_kv(effect) + if (kv.get("mode") or "").strip().lower() == "self_convert": + before = self._player_scores_line(player) + payout = self._prompt_or_apply_self_convert(effect, player, domain) + if isinstance(self.action_required, dict) and self.action_required.get("action"): + self._log_game_event( + f"{self._player_label(player.player_id)} triggered activation effect on \"{getattr(domain, 'name', 'Domain')}\" and is choosing options." + ) + return + if isinstance(payout, list) and len(payout) >= 1 and payout[0] == -9999: + return + player.gold_score = int(player.gold_score) + payout[0] + player.strength_score = int(player.strength_score) + payout[1] + player.magic_score = int(player.magic_score) + payout[2] + player.victory_score = int(getattr(player, "victory_score", 0)) + payout[3] + self._bump_harvest_delta(player, payout[0], payout[1], payout[2], payout[3]) + after = self._player_scores_line(player) + if before != after: + self._log_game_event( + f"{self._player_label(player.player_id)} activated domain \"{getattr(domain, 'name', 'Domain')}\"; scores {before} -> {after}" + ) + return + before = self._player_scores_line(player) + _prior_action = (self.action_required or {}).get("action", "") + _prior_concurrent = getattr(self, "concurrent_action", None) + payout = self.execute_special_payout(effect, player.player_id, auto_apply_single_choice=False) + _new_action = (self.action_required or {}).get("action", "") + _new_concurrent = getattr(self, "concurrent_action", None) + if (_new_action and _new_action != _prior_action) or (_new_concurrent is not _prior_concurrent): + # Compound payouts (e.g. Cloudrider's Camp: "s 3 + choose ") resolve the + # resource leg before the blocking choose; apply those gains now so they are not lost. + if isinstance(payout, list) and len(payout) >= 4 and payout[0] != -9999: + player.gold_score = int(player.gold_score) + payout[0] + player.strength_score = int(player.strength_score) + payout[1] + player.magic_score = int(player.magic_score) + payout[2] + player.victory_score = int(getattr(player, "victory_score", 0)) + payout[3] + self._bump_harvest_delta(player, payout[0], payout[1], payout[2], payout[3]) + self._log_game_event( + f"{self._player_label(player.player_id)} triggered activation effect on \"{getattr(domain, 'name', 'Domain')}\" and is choosing options." + ) + return + if isinstance(payout, list) and len(payout) >= 1 and payout[0] == -9999: + return + player.gold_score = int(player.gold_score) + payout[0] + player.strength_score = int(player.strength_score) + payout[1] + player.magic_score = int(player.magic_score) + payout[2] + player.victory_score = int(getattr(player, "victory_score", 0)) + payout[3] + self._bump_harvest_delta(player, payout[0], payout[1], payout[2], payout[3]) + after = self._player_scores_line(player) + if before != after: + self._log_game_event( + f"{self._player_label(player.player_id)} activated domain \"{getattr(domain, 'name', 'Domain')}\"; scores {before} -> {after}" + ) + + def _filter_unavailable_choose_options(self, options): + out = [] + for opt in options or []: + token = (opt.get("token") or "").strip().lower() + if token == "citizens_where": + spec = opt.get("spec") or {} + count = int(opt.get("amount", 1) or 1) + if len(self._board_citizen_candidates(spec)) < count: + continue + out.append(opt) + return out + + def _expand_choose_options_for_prompt(self, options): + expanded = [] + for opt in options or []: + token = (opt.get("token") or "").strip().lower() + if token in ("g", "s", "m", "v"): + expanded.append({"token": token, "amount": int(opt.get("amount", 0) or 0)}) + continue + if token == "count_area": + expanded.append(opt) + continue + if token != "citizens_where": + continue + if int(opt.get("amount", 1) or 1) != 1: + continue + spec = opt.get("spec") or {} + candidates = self._board_citizen_candidates(spec) + for c in candidates: + extras = list(opt.get("extras") or []) + expanded.append({ + "token": "citizens.choice", + "amount": 1, + "citizen_id": c.citizen_id, + "name": c.name, + "gold_cost": int(getattr(c, "gold_cost", 0) or 0), + "extras": extras, + }) + return expanded + + def _claim_specific_board_citizen(self, player_id, citizen_id): + target = self._player_by_id(player_id) + if not target: + return False + try: + wanted = int(citizen_id) + except (TypeError, ValueError): + return False + for stack in self.citizen_grid: + if not stack: + continue + top = stack[-1] + if not getattr(top, "is_accessible", False): + continue + if int(getattr(top, "citizen_id", -1)) != wanted: + continue + claimed = stack.pop(-1) + self._citizen_set_flipped(claimed, False) + target.owned_citizens.append(claimed) + if stack: + stack[-1].toggle_accessibility(True) + return True + return False + + def _apply_choose_option(self, player_id, opt): + target = self._player_by_id(player_id) + if not target: + return False + token = (opt.get("token") or "").strip().lower() + amount = int(opt.get("amount", 0)) + if amount <= 0 and token not in ("count_area",): + return False + if token == "citizens.choice": + if not self._claim_specific_board_citizen(player_id, opt.get("citizen_id")): + return False + for e in list(opt.get("extras") or []): + t = (e.get("token") or "").strip().lower() + n = int(e.get("amount", 0) or 0) + if t == "g": + target.gold_score = int(target.gold_score) + n + self._bump_harvest_delta(target, n, 0, 0, 0) + elif t == "s": + target.strength_score = int(target.strength_score) + n + self._bump_harvest_delta(target, 0, n, 0, 0) + elif t == "m": + target.magic_score = int(target.magic_score) + n + self._bump_harvest_delta(target, 0, 0, n, 0) + elif t == "v": + target.victory_score = int(getattr(target, "victory_score", 0)) + n + self._bump_harvest_delta(target, 0, 0, 0, n) + else: + return False + return True + if token == "count_area": + area = opt.get("area") + resource = (opt.get("resource") or "").strip().lower() + mult = int(opt.get("mult", 0) or 0) + count = int((self.owned_monster_attributes(player_id) or {}).get(area, 0) or 0) + total = count * mult + if resource == "g": + target.gold_score = int(target.gold_score) + total + self._bump_harvest_delta(target, total, 0, 0, 0) + elif resource == "s": + target.strength_score = int(target.strength_score) + total + self._bump_harvest_delta(target, 0, total, 0, 0) + elif resource == "m": + target.magic_score = int(target.magic_score) + total + self._bump_harvest_delta(target, 0, 0, total, 0) + elif resource == "v": + target.victory_score = int(getattr(target, "victory_score", 0)) + total + self._bump_harvest_delta(target, 0, 0, 0, total) + else: + return False + return True + dg = ds = dm = dv = 0 + if token == "g": + dg = amount + elif token == "s": + ds = amount + elif token == "m": + dm = amount + elif token == "v": + dv = amount + else: + return False + target.gold_score = int(target.gold_score) + int(dg) + target.strength_score = int(target.strength_score) + int(ds) + target.magic_score = int(target.magic_score) + int(dm) + target.victory_score = int(getattr(target, "victory_score", 0)) + int(dv) + if not hasattr(target, "harvest_delta") or not isinstance(target.harvest_delta, dict): + target.harvest_delta = {"gold": 0, "strength": 0, "magic": 0, "victory": 0} + self._bump_harvest_delta(target, dg, ds, dm, dv) + return True + + def _describe_choose_option(self, opt): + token = (opt.get("token") or "").strip().lower() + if token in ("g", "s", "m", "v"): + label = {"g": "gold", "s": "strength", "m": "magic", "v": "victory"}[token] + return f"+{int(opt.get('amount', 0) or 0)} {label}" + if token == "count_area": + area = opt.get("area") + resource = (opt.get("resource") or "").strip().lower() + mult = int(opt.get("mult", 0) or 0) + label = {"g": "gold", "s": "strength", "m": "magic", "v": "victory"}.get(resource, resource) + return f"+({mult} x {area}) {label}" + if token == "citizens.choice": + name = (opt.get("name") or "Citizen").strip() + extras = list(opt.get("extras") or []) + suffix = "" + if extras: + parts = [] + for e in extras: + et = (e.get("token") or "").strip().lower() + ea = int(e.get("amount", 0) or 0) + el = {"g": "gold", "s": "strength", "m": "magic", "v": "victory"}.get(et, et) + parts.append(f"+{ea} {el}") + suffix = " + " + " + ".join(parts) + return f"gain 1 {name} citizen{suffix}" + return f"{token} {opt.get('amount')}" + def owned_monster_attributes(self, player_id): return_dict = {attr: 0 for attr in Constants.areas + Constants.types} for player in self.player_list: @@ -1082,10 +2282,108 @@ class Game: self.action_required['id'] = self.game_id return + prc0 = getattr(self, "pending_required_choice", None) or {} + if prc0.get("kind") == "domain_boost_monster" and str(current_required).strip() == "choose_monster_strength": + act = (action or "").strip().lower() + opts = list(prc0.get("options") or []) + if not act.startswith("choose_monster "): + return + try: + idx = int(act.split()[1]) - 1 + except (IndexError, ValueError): + return + if idx < 0 or idx >= len(opts): + return + target = self._player_by_id(player_id) + if not target: + return + mid = int(opts[idx].get("monster_id", -1)) + delta = int(prc0.get("delta", 0) or 0) + if not self._apply_monster_strength_boost(mid, delta): + return + self._log_game_event( + f"{self._player_label(player_id)} chose \"{opts[idx].get('name', '?')}\" for " + f"\"{prc0.get('domain_name', 'Domain')}\" (+{delta} strength cost)." + ) + self.action_required["action"] = "" + self.action_required["id"] = self.game_id + self.pending_required_choice = None + return + + if prc0.get("kind") == "domain_self_convert" and str(current_required).strip() == "domain_self_convert": + act = (action or "").strip().lower() + if player_id != prc0.get("player_id"): + return + if act == "skip": + self._resume_after_domain_activation_follow_up() + return + if act != "confirm_self_convert": + return + kv = prc0.get("kv") or {} + pay_k, pay_n = _parse_resource_kv(kv.get("pay", "")) + target = self._player_by_id(player_id) + if not target or not pay_k or pay_n <= 0: + return + if not self._player_can_afford_self_convert_resources(target, pay_k, pay_n): + return + before = self._player_scores_line(target) + self._apply_self_convert_kv_to_player(target, kv) + after = self._player_scores_line(target) + self._log_game_event( + f"{self._player_label(player_id)} confirmed \"{prc0.get('domain_name', 'Domain')}\" trade; scores {before} -> {after}" + ) + self._resume_after_domain_activation_follow_up() + return + + if prc0.get("kind") == "domain_manipulate_player" and str(current_required).strip() == "choose_player": + act = (action or "").strip().lower() + if prc0.get("allow_skip") and act == "skip": + if self.pending_action_end_queue: + self.pending_action_end_queue.pop(0) + self.action_required["action"] = "" + self.action_required["id"] = self.game_id + self.pending_required_choice = None + if not self._drain_action_end_manipulate_queue(): + self.action_required["id"] = self.game_id + self.action_required["action"] = "" + return + opts = list(prc0.get("options") or []) + if not act.startswith("choose_player "): + return + try: + idx = int(act.split()[1]) - 1 + except (IndexError, ValueError): + return + if idx < 0 or idx >= len(opts): + return + item = prc0.get("item") or {} + tid = opts[idx].get("player_id") + self._apply_manipulate_player_choice(player_id, tid, item) + if self.pending_action_end_queue: + self.pending_action_end_queue.pop(0) + self.action_required["action"] = "" + self.action_required["id"] = self.game_id + self.pending_required_choice = None + if not self._drain_action_end_manipulate_queue(): + self.action_required["id"] = self.game_id + self.action_required["action"] = "" + return + # Resolve a blocking "choose ..." special payout prompt. - if str(current_required).strip().lower().startswith("choose"): + if str(current_required).strip().lower().startswith("choose "): + prc = getattr(self, "pending_required_choice", None) or {} normalized, options = self._normalize_choose_command(current_required) + if prc.get("kind") == "special_payout_choose": + options = list(prc.get("options") or []) + else: + options = self._expand_choose_options_for_prompt( + self._filter_unavailable_choose_options(options) + ) if not options: + self.action_required["action"] = "" + self.action_required["id"] = self.game_id + self.pending_required_choice = None + self._maybe_resume_harvest_prompt() return act = (action or "").strip().lower() if not act.startswith("choose "): @@ -1101,26 +2399,12 @@ class Game: if not target: return before = self._player_scores_line(target) - dg = ds = dm = dv = 0 - if opt["token"] == "g": - dg = opt["amount"] - elif opt["token"] == "s": - ds = opt["amount"] - elif opt["token"] == "m": - dm = opt["amount"] - else: - dv = opt["amount"] - target.gold_score = int(target.gold_score) + int(dg) - target.strength_score = int(target.strength_score) + int(ds) - target.magic_score = int(target.magic_score) + int(dm) - target.victory_score = int(getattr(target, "victory_score", 0)) + int(dv) - if not hasattr(target, "harvest_delta") or not isinstance(target.harvest_delta, dict): - target.harvest_delta = {"gold": 0, "strength": 0, "magic": 0, "victory": 0} - self._bump_harvest_delta(target, dg, ds, dm, dv) + if not self._apply_choose_option(player_id, opt): + return after = self._player_scores_line(target) self._log_game_event( f"{self._player_label(player_id)} chose ({idx + 1}/{len(options)}) from \"{normalized}\": " - f"{opt['token']} {opt['amount']}; scores {before} -> {after}" + f"{self._describe_choose_option(opt)}; scores {before} -> {after}" ) # Clear the prompt, then resume harvest automation if applicable. self.action_required["action"] = "" @@ -1199,6 +2483,35 @@ class Game: else: payout[0] = -9999 + def _player_citizen_role_totals(self, player): + totals = {"shadow": 0, "holy": 0, "soldier": 0, "worker": 0} + for c in list(getattr(player, "owned_citizens", []) or []): + totals["shadow"] += int(getattr(c, "shadow_count", 0) or 0) + totals["holy"] += int(getattr(c, "holy_count", 0) or 0) + totals["soldier"] += int(getattr(c, "soldier_count", 0) or 0) + totals["worker"] += int(getattr(c, "worker_count", 0) or 0) + return totals + + def _player_has_action_effect_flag(self, player, flag_name): + target = (flag_name or "").strip().lower() + if not target: + return False + for d in list(getattr(player, "owned_domains", []) or []): + if self._domain_recurring_passive_on_build_turn_cooldown(d): + continue + name = str(getattr(d, "name", "") or "").strip().lower() + text = str(getattr(d, "text", "") or "").strip().lower() + raw = (getattr(d, "passive_effect", None) or "") + effect = str(raw).strip().lower() + if effect: + if effect == target: + return True + if effect.startswith("effect.add "): + added = effect[len("effect.add "):].strip() + if added == target: + return True + return False + def hire_citizen(self, player_id, citizen_id, gp=0, mp=0, sp=0): """ Hire the top/accessible citizen from a stack. @@ -1226,12 +2539,14 @@ class Game: raise ValueError("Player not found.") owned_same_name = 0 - for c in getattr(player, "owned_citizens", []) or []: - if getattr(c, "name", None) == top.name: - owned_same_name += 1 - for s in getattr(player, "owned_starters", []) or []: - if getattr(s, "name", None) == top.name: - owned_same_name += 1 + has_emerald = self._player_has_action_effect_flag(player, "action.emeraldstronghold") + if not has_emerald: + for c in getattr(player, "owned_citizens", []) or []: + if getattr(c, "name", None) == top.name: + owned_same_name += 1 + for s in getattr(player, "owned_starters", []) or []: + if getattr(s, "name", None) == top.name: + owned_same_name += 1 scaled_cost = int(getattr(top, "gold_cost", 0) or 0) + int(owned_same_name) _validate_hire_or_domain_gold_payment(player, scaled_cost, gp, sp, mp) @@ -1239,7 +2554,9 @@ class Game: before = self._player_scores_line(player) player.gold_score = player.gold_score - gp player.magic_score = player.magic_score - mp - player.owned_citizens.append(citizen_stack.pop(-1)) + hired = citizen_stack.pop(-1) + self._citizen_set_flipped(hired, False) + player.owned_citizens.append(hired) if citizen_stack: citizen_stack[-1].toggle_accessibility(True) @@ -1283,6 +2600,9 @@ class Game: if top.has_special_reward: payout = self.execute_special_payout(top.special_reward, player_id) + if isinstance(payout, list) and len(payout) >= 1 and payout[0] == -9999: + if not (isinstance(self.action_required, dict) and self.action_required.get("action")): + payout = [0, 0, 0, 0] payout[0] = payout[0] + top.gold_reward payout[1] = payout[1] + top.strength_reward payout[2] = payout[2] + top.magic_reward @@ -1304,7 +2624,7 @@ class Game: raise ValueError("Monster not available to slay.") - def buy_domain(self, player_id, domain_id, gp=0, mp=0, sp=0): + def build_domain(self, player_id, domain_id, gp=0, mp=0, sp=0): gp, sp, mp = _n(gp), _n(sp), _n(mp) for domain_stack in self.domain_grid: @@ -1326,16 +2646,49 @@ class Game: if not player: raise ValueError("Player not found.") + # Domain role prerequisites must be satisfied by owned citizens. + # Starters and already-owned domains do not count toward this gate. + have = self._player_citizen_role_totals(player) + req_shadow = int(getattr(top, "shadow_count", 0) or 0) + req_holy = int(getattr(top, "holy_count", 0) or 0) + req_soldier = int(getattr(top, "soldier_count", 0) or 0) + req_worker = int(getattr(top, "worker_count", 0) or 0) + missing = [] + if have["shadow"] < req_shadow: + missing.append(f"shadow {have['shadow']}/{req_shadow}") + if have["holy"] < req_holy: + missing.append(f"holy {have['holy']}/{req_holy}") + if have["soldier"] < req_soldier: + missing.append(f"soldier {have['soldier']}/{req_soldier}") + if have["worker"] < req_worker: + missing.append(f"worker {have['worker']}/{req_worker}") + if missing: + raise ValueError( + "Domain role requirements not met (citizens only): " + ", ".join(missing) + ) + gold_cost = int(getattr(top, "gold_cost", 0) or 0) + has_pratchett = self._player_has_action_effect_flag(player, "action.pratchettsplateau") + if has_pratchett: + gold_cost = max(0, gold_cost - 1) _validate_hire_or_domain_gold_payment(player, gold_cost, gp, sp, mp) before = self._player_scores_line(player) player.gold_score = player.gold_score - gp player.magic_score = player.magic_score - mp - player.owned_domains.append(domain_stack.pop(-1)) + bought = domain_stack.pop(-1) + bought.acquired_turn_number = int(self.turn_number) + player.owned_domains.append(bought) + + vp_gain = int(getattr(bought, "vp_reward", 0) or 0) + if vp_gain: + player.victory_score = int(getattr(player, "victory_score", 0) or 0) + vp_gain + self._bump_harvest_delta(player, 0, 0, 0, vp_gain) if domain_stack: + domain_stack[-1].toggle_visibility(True) domain_stack[-1].toggle_accessibility(True) + self._apply_domain_activation_effect(player, bought) after = self._player_scores_line(player) pay = self._format_resource_payment(gp, sp, mp) self._log_game_event( @@ -1389,402 +2742,3 @@ class Game: def prompt(self): return - - -class Player: - def __init__(self, player_id, name): - self.player_id = player_id - self.name = name - self.owned_starters = [] - self.owned_citizens = [] - self.owned_domains = [] - self.owned_dukes = [] - self.owned_monsters = [] - self.gold_score = 2 - self.strength_score = 0 - self.magic_score = 1 - self.victory_score = 0 - self.is_first = False - self.shadow_count = 0 - self.holy_count = 0 - self.soldier_count = 0 - self.worker_count = 0 - self.effects = { - "roll_phase": [], - "harvest_phase": [], - "action_phase": [] - } - self.harvest_delta = {"gold": 0, "strength": 0, "magic": 0, "victory": 0} - - @classmethod - def from_dict(cls, data): - player_id = data['player_id'] - name = data['name'] - player = cls(player_id, name) - player.owned_starters = [Starter.from_dict(s) for s in data['owned_starters']] - player.owned_citizens = [Citizen.from_dict(c) for c in data['owned_citizens']] - player.owned_domains = [Domain.from_dict(d) for d in data['owned_domains']] - player.owned_dukes = [Duke.from_dict(d) for d in data['owned_dukes']] - player.owned_monsters = [Monster.from_dict(m) for m in data['owned_monsters']] - player.gold_score = data['gold_score'] - player.strength_score = data['strength_score'] - player.magic_score = data['magic_score'] - player.victory_score = data['victory_score'] - player.is_first = data['is_first'] - player.effects = data['effects'] - player.harvest_delta = data.get('harvest_delta', {"gold": 0, "strength": 0, "magic": 0, "victory": 0}) - roles = player.calc_roles() - player.shadow_count = roles['shadow_count'] - player.holy_count = roles['holy_count'] - player.soldier_count = roles['soldier_count'] - player.worker_count = roles['worker_count'] - return player - - def calc_roles(self): - shadow_count = 0 - holy_count = 0 - soldier_count = 0 - worker_count = 0 - for citizen in self.owned_citizens: - shadow_count = shadow_count + citizen.shadow_count - holy_count = holy_count + citizen.holy_count - soldier_count = soldier_count + citizen.soldier_count - worker_count = worker_count + citizen.worker_count - for domain in self.owned_domains: - shadow_count = shadow_count + domain.shadow_count - holy_count = holy_count + domain.holy_count - soldier_count = soldier_count + domain.soldier_count - worker_count = worker_count + domain.worker_count - roles_dict = { - "shadow_count": shadow_count, - "holy_count": holy_count, - "soldier_count": soldier_count, - "worker_count": worker_count - } - return roles_dict - - -class LobbyMember: - def __init__(self, player_name, player_id): - self.name = player_name - self.player_id = player_id - self.is_ready = False - self.last_active_time = 0 - - -class GameMember: - def __init__(self, player_id, player_name, game_id): - self.name = player_name - self.player_id = player_id - self.game_id = game_id - - -def load_game_data(game_id, preset, player_list_from_lobby): - monster_query = "" - monster_stack = [] - citizen_query = "" - citizen_stack = [] - domain_query = "select_random_domains" - domain_stack = [] - duke_query = "select_random_dukes" - duke_stack = [] - starter_query = "SELECT * FROM starters" - starter_stack = [] - player_list = [] - citizen_grid: List[List[Citizen]] = [[] for _ in range(10)] - domain_grid: List[List[Domain]] = [[] for _ in range(5)] - monster_grid: List[List[Monster]] = [[] for _ in range(5)] - die_one = 0 - die_two = 0 - die_sum = 0 - exhausted_count = 0 - effects = { - "roll_phase": [], - "harvest_phase": [], - "action_phase": [] - } - action_required = { - "id": "", - "action": "" - } - tick_id = 0 - turn_number = 1 - turn_index = 0 - # Start in setup; if no setup actions are needed the engine will advance into roll. - phase = 'setup' - actions_remaining = 0 - harvest_processed = False - pending_harvest_choices = [] - match preset: - case "base1": - monster_query = "select_base1_monsters" - citizen_query = "select_base1_citizens" - case "base2": - monster_query = "select_base2_monsters" - citizen_query = "select_base2_citizens" - try: - my_connect = mariadb.connect(user='vckonline', password='vckonline', host='127.0.0.1', - database='vckonline', port=3306) - my_cursor = my_connect.cursor(dictionary=True) - - my_cursor.callproc(monster_query) - - results = my_cursor.fetchall() - for row in results: - my_monster = Monster(row['id_monsters'], row['name'], row['area'], row['monster_type'], - row['monster_order'], row['strength_cost'], row['magic_cost'], row['vp_reward'], - row['gold_reward'], row['strength_reward'], row['magic_reward'], - row['has_special_reward'], row['special_reward'], row['has_special_cost'], - row['special_cost'], row['is_extra'], row['expansion']) - monster_stack.append(my_monster) - - my_cursor.callproc(citizen_query) - citizen_count = 5 - if len(player_list_from_lobby) == 5: - citizen_count = 6 - results = my_cursor.fetchall() - for row in results: - for i in range(citizen_count): - my_citizen = Citizen(row['id_citizens'], row['name'], row['gold_cost'], row['roll_match1'], - row['roll_match2'], row['shadow_count'], row['holy_count'], row['soldier_count'], - row['worker_count'], row['gold_payout_on_turn'], row['gold_payout_off_turn'], - row['strength_payout_on_turn'], row['strength_payout_off_turn'], - row['magic_payout_on_turn'], row['magic_payout_off_turn'], - row['has_special_payout_on_turn'], row['has_special_payout_off_turn'], - row['special_payout_on_turn'], row['special_payout_off_turn'], - row['special_citizen'], - row['expansion']) - citizen_stack.append(my_citizen) - - my_cursor.callproc(domain_query) - results = my_cursor.fetchall() - for row in results: - my_domain = Domain(row['id_domains'], row['name'], row['gold_cost'], row['shadow_count'], row['holy_count'], - row['soldier_count'], row['worker_count'], row['vp_reward'], - row['has_activation_effect'], row['has_passive_effect'], row['passive_effect'], - row['activation_effect'], row['text'], row['expansion']) - domain_stack.append(my_domain) - - my_cursor.callproc(duke_query) - results = my_cursor.fetchall() - for row in results: - my_duke = Duke(row['id_dukes'], row['name'], row['gold_mult'], row['strength_mult'], row['magic_mult'], - row['shadow_mult'], row['holy_mult'], row['soldier_mult'], row['worker_mult'], - row['monster_mult'], row['citizen_mult'], row['domain_mult'], row['boss_mult'], - row['minion_mult'], row['beast_mult'], row['titan_mult'], row['expansion']) - duke_stack.append(my_duke) - - my_cursor.execute(starter_query) - my_result = my_cursor.fetchall() - for row in my_result: - my_starter = Starter(row['id_starters'], row['name'], row['roll_match1'], row['roll_match2'], - row['gold_payout_on_turn'], row['gold_payout_off_turn'], - row['strength_payout_on_turn'], row['strength_payout_off_turn'], - row['magic_payout_on_turn'], row['magic_payout_off_turn'], - row['has_special_payout_on_turn'], row['has_special_payout_off_turn'], - row['special_payout_on_turn'], row['special_payout_off_turn'], row['expansion']) - starter_stack.append(my_starter) - my_cursor.close() - my_connect.close() - except Exception as e: - print(f"Error: {e}") - # print(f"size of monster stack: {len(monster_stack)}") - # print(f"size of citizen stack: {len(citizen_stack)}") - # print(f"size of domain stack: {len(domain_stack)}") - # print(f"size of duke stack: {len(duke_stack)}") - # print(f"size of starter stack: {len(starter_stack)}") - # create players and determine order - if not all([player_list_from_lobby, starter_query, monster_stack, citizen_stack, domain_stack, duke_stack]): - raise ValueError("One or more required lists are empty.") - else: - for player in player_list_from_lobby: - my_player = Player(player.player_id, player.name) - player_list.append(my_player) - random.shuffle(player_list) - player_list[0].is_first = True - # give players starters and dukes - for player in player_list: - player.owned_starters.append(starter_stack[0]) - player.owned_starters.append(starter_stack[1]) - for i in range(2): - player.owned_dukes.append(duke_stack.pop()) - # deal monsters onto the board - grouped_monsters = {} - for monster in monster_stack: - area = monster.area - if area in grouped_monsters: - grouped_monsters[area].append(monster) - else: - grouped_monsters[area] = [monster] - # Reverse the order of each group by monster_order - for area, monsters in grouped_monsters.items(): - monsters.sort(key=lambda item: item.order, reverse=True) - areas = list(grouped_monsters.keys()) - chosen_areas = random.sample(areas, 5) - for i, area in enumerate(chosen_areas): - monsters = grouped_monsters[area] - monster_grid[i].extend(monsters) - for i, stack in enumerate(monster_grid): - for monster in stack: - monster.toggle_visibility(True) - # Make the last monster in the stack accessible - stack[-1].toggle_accessibility(True) - # deal citizens onto the board - # Create a dictionary to store citizen lists with roll numbers as keys - citizens_by_roll = {roll: [] for roll in [1, 2, 3, 4, 5, 6, 7, 8, 9, 11]} - # Group citizens by roll number - for citizen in citizen_stack: - citizen.toggle_visibility() - citizens_by_roll[citizen.roll_match1].append(citizen) - for roll in citizens_by_roll: - # Map 11 roll to index 9 - index = roll - 1 if roll < 11 else 9 - citizens = citizens_by_roll[roll] - citizen_grid[index].extend(list(citizens)) - # Make the first citizen in each list accessible - citizen_grid[index][-1].toggle_accessibility(True) - # Deal the domains into the stacks - for i in range(5): - stack = domain_grid[i] - for j in range(3): - if j == 2: # top domain is visible and accessible - domain = domain_stack.pop() - domain.toggle_visibility(True) - domain.toggle_accessibility(True) - stack.append(domain) - else: # other domains are not visible or accessible - domain = domain_stack.pop() - stack.append(domain) - - # Create a dictionary to store all the stacks - game_state = {'game_id': game_id, - 'player_list': player_list, - 'monster_grid': monster_grid, - 'citizen_grid': citizen_grid, - 'domain_grid': domain_grid, - 'die_one': die_one, - 'die_two': die_two, - 'die_sum': die_sum, - 'exhausted_count': exhausted_count, - 'effects': effects, - 'action_required': action_required, - 'concurrent_action': None, - 'tick_id': tick_id, - 'turn_number': turn_number, - 'turn_index': turn_index, - 'phase': phase, - 'actions_remaining': actions_remaining, - 'harvest_processed': harvest_processed, - 'pending_harvest_choices': pending_harvest_choices, - 'harvest_player_order': None, - 'harvest_player_idx': 0, - 'harvest_consumed': {}, - 'game_log': []} - # Return the dictionary - return game_state - - -class SummaryEncoder(JSONEncoder): - def default(self, obj): - if isinstance(obj, Player): - return { - 'player_id': obj.player_id, - 'name': obj.name, - 'owned_citizens': len(obj.owned_citizens), - 'owned_domains': len(obj.owned_domains), - 'owned_monsters': len(obj.owned_monsters), - 'gold_score': obj.gold_score, - 'strength_score': obj.strength_score, - 'magic_score': obj.magic_score, - 'victory_score': obj.victory_score, - 'is_first': obj.is_first - } - elif isinstance(obj, LobbyMember): - return { - "player_name": obj.name, - "player_id": obj.player_id, - "is_ready": obj.is_ready - } - elif isinstance(obj, GameMember): - return { - "player_name": obj.name, - "player_id": obj.player_id - } - elif isinstance(obj, Game): - return { - "game_id": obj.game_id, - "player_list": obj.player_list - } - else: - return super().default(obj) - - -class GameObjectEncoder(JSONEncoder): - def default(self, obj): - if isinstance(obj, Player): - # Role totals come from owned citizens + domains (see calc_roles); keep JSON aligned with gameplay. - roles = obj.calc_roles() - return { - 'player_id': obj.player_id, - 'name': obj.name, - # Dev client wants to render a tableau; include full objects (not just ids). - 'owned_starters': [starter.to_dict() for starter in obj.owned_starters], - 'owned_citizens': [citizen.to_dict() for citizen in obj.owned_citizens], - 'owned_domains': [domain.to_dict() for domain in obj.owned_domains], - 'owned_dukes': [duke.to_dict() for duke in obj.owned_dukes], - 'owned_monsters': [monster.to_dict() for monster in obj.owned_monsters], - 'gold_score': obj.gold_score, - 'strength_score': obj.strength_score, - 'magic_score': obj.magic_score, - 'victory_score': obj.victory_score, - 'is_first': obj.is_first, - 'shadow_count': roles['shadow_count'], - 'holy_count': roles['holy_count'], - 'soldier_count': roles['soldier_count'], - 'worker_count': roles['worker_count'], - 'effects': obj.effects, - 'harvest_delta': getattr(obj, "harvest_delta", {"gold": 0, "strength": 0, "magic": 0, "victory": 0}) - } - elif isinstance(obj, Duke): - return obj.to_dict() - elif isinstance(obj, Monster): - return obj.to_dict() - elif isinstance(obj, Starter): - return obj.to_dict() - elif isinstance(obj, Citizen): - return obj.to_dict() - elif isinstance(obj, Domain): - return obj.to_dict() - elif isinstance(obj, Game): - base = { - "game_id": obj.game_id, - "player_list": obj.player_list, - "monster_grid": obj.monster_grid, - "citizen_grid": obj.citizen_grid, - "domain_grid": obj.domain_grid, - "die_one": obj.die_one, - "die_two": obj.die_two, - "die_sum": obj.die_sum, - "rolled_die_one": getattr(obj, "rolled_die_one", obj.die_one), - "rolled_die_two": getattr(obj, "rolled_die_two", obj.die_two), - "rolled_die_sum": getattr(obj, "rolled_die_sum", obj.die_sum), - "pending_roll": getattr(obj, "pending_roll", None), - "exhausted_count": obj.exhausted_count, - "effects": obj.effects, - "action_required": obj.action_required, - "concurrent_action": getattr(obj, "concurrent_action", None), - "tick_id": getattr(obj, "tick_id", 0), - "turn_number": getattr(obj, "turn_number", 1), - "turn_index": getattr(obj, "turn_index", 0), - "phase": getattr(obj, "phase", "roll"), - "actions_remaining": getattr(obj, "actions_remaining", 0), - "active_player_id": obj.current_player_id() if hasattr(obj, "current_player_id") else None, - "harvest_player_order": getattr(obj, "harvest_player_order", None), - "harvest_player_idx": getattr(obj, "harvest_player_idx", 0), - "harvest_consumed": getattr(obj, "harvest_consumed", {}) or {}, - "harvest_prompt_slots": obj.harvest_slots_for_api() if hasattr(obj, "harvest_slots_for_api") else [], - "game_log": list(getattr(obj, "game_log", None) or []), - } - return base - else: - return super().default(obj) diff --git a/game_models.py b/game_models.py new file mode 100644 index 0000000..47ec741 --- /dev/null +++ b/game_models.py @@ -0,0 +1,90 @@ +from cards import Citizen, Domain, Duke, Monster, Starter + + +class Player: + def __init__(self, player_id, name): + self.player_id = player_id + self.name = name + self.owned_starters = [] + self.owned_citizens = [] + self.owned_domains = [] + self.owned_dukes = [] + self.owned_monsters = [] + self.gold_score = 2 + self.strength_score = 0 + self.magic_score = 1 + self.victory_score = 0 + self.is_first = False + self.shadow_count = 0 + self.holy_count = 0 + self.soldier_count = 0 + self.worker_count = 0 + self.effects = { + "roll_phase": [], + "harvest_phase": [], + "action_phase": [], + } + self.harvest_delta = {"gold": 0, "strength": 0, "magic": 0, "victory": 0} + + @classmethod + def from_dict(cls, data): + player_id = data["player_id"] + name = data["name"] + player = cls(player_id, name) + player.owned_starters = [Starter.from_dict(s) for s in data["owned_starters"]] + player.owned_citizens = [Citizen.from_dict(c) for c in data["owned_citizens"]] + player.owned_domains = [Domain.from_dict(d) for d in data["owned_domains"]] + player.owned_dukes = [Duke.from_dict(d) for d in data["owned_dukes"]] + player.owned_monsters = [Monster.from_dict(m) for m in data["owned_monsters"]] + player.gold_score = data["gold_score"] + player.strength_score = data["strength_score"] + player.magic_score = data["magic_score"] + player.victory_score = data["victory_score"] + player.is_first = data["is_first"] + player.effects = data["effects"] + player.harvest_delta = data.get("harvest_delta", {"gold": 0, "strength": 0, "magic": 0, "victory": 0}) + roles = player.calc_roles() + player.shadow_count = roles["shadow_count"] + player.holy_count = roles["holy_count"] + player.soldier_count = roles["soldier_count"] + player.worker_count = roles["worker_count"] + return player + + def calc_roles(self): + shadow_count = 0 + holy_count = 0 + soldier_count = 0 + worker_count = 0 + for citizen in self.owned_citizens: + shadow_count = shadow_count + citizen.shadow_count + holy_count = holy_count + citizen.holy_count + soldier_count = soldier_count + citizen.soldier_count + worker_count = worker_count + citizen.worker_count + for domain in self.owned_domains: + shadow_count = shadow_count + domain.shadow_count + holy_count = holy_count + domain.holy_count + soldier_count = soldier_count + domain.soldier_count + worker_count = worker_count + domain.worker_count + roles_dict = { + "shadow_count": shadow_count, + "holy_count": holy_count, + "soldier_count": soldier_count, + "worker_count": worker_count, + } + return roles_dict + + +class LobbyMember: + def __init__(self, player_name, player_id): + self.name = player_name + self.player_id = player_id + self.is_ready = False + self.debug_starting_resources = False + self.last_active_time = 0 + + +class GameMember: + def __init__(self, player_id, player_name, game_id): + self.name = player_name + self.player_id = player_id + self.game_id = game_id diff --git a/game_serialization.py b/game_serialization.py new file mode 100644 index 0000000..e105ed5 --- /dev/null +++ b/game_serialization.py @@ -0,0 +1,111 @@ +from json import JSONEncoder + +from cards import Citizen, Domain, Duke, Monster, Starter +from game_models import GameMember, LobbyMember, Player + + +class SummaryEncoder(JSONEncoder): + def default(self, obj): + if isinstance(obj, Player): + return { + "player_id": obj.player_id, + "name": obj.name, + "owned_citizens": len(obj.owned_citizens), + "owned_domains": len(obj.owned_domains), + "owned_monsters": len(obj.owned_monsters), + "gold_score": obj.gold_score, + "strength_score": obj.strength_score, + "magic_score": obj.magic_score, + "victory_score": obj.victory_score, + "is_first": obj.is_first, + } + if isinstance(obj, LobbyMember): + return { + "player_name": obj.name, + "player_id": obj.player_id, + "is_ready": obj.is_ready, + } + if isinstance(obj, GameMember): + return { + "player_name": obj.name, + "player_id": obj.player_id, + } + if hasattr(obj, "game_id") and hasattr(obj, "player_list"): + return { + "game_id": obj.game_id, + "player_list": obj.player_list, + } + return super().default(obj) + + +class GameObjectEncoder(JSONEncoder): + def default(self, obj): + if isinstance(obj, Player): + roles = obj.calc_roles() + return { + "player_id": obj.player_id, + "name": obj.name, + "owned_starters": [starter.to_dict() for starter in obj.owned_starters], + "owned_citizens": [citizen.to_dict() for citizen in obj.owned_citizens], + "owned_domains": [domain.to_dict() for domain in obj.owned_domains], + "owned_dukes": [duke.to_dict() for duke in obj.owned_dukes], + "owned_monsters": [monster.to_dict() for monster in obj.owned_monsters], + "gold_score": obj.gold_score, + "strength_score": obj.strength_score, + "magic_score": obj.magic_score, + "victory_score": obj.victory_score, + "is_first": obj.is_first, + "shadow_count": roles["shadow_count"], + "holy_count": roles["holy_count"], + "soldier_count": roles["soldier_count"], + "worker_count": roles["worker_count"], + "effects": obj.effects, + "harvest_delta": getattr(obj, "harvest_delta", {"gold": 0, "strength": 0, "magic": 0, "victory": 0}), + } + if isinstance(obj, Duke): + return obj.to_dict() + if isinstance(obj, Monster): + return obj.to_dict() + if isinstance(obj, Starter): + return obj.to_dict() + if isinstance(obj, Citizen): + return obj.to_dict() + if isinstance(obj, Domain): + return obj.to_dict() + if hasattr(obj, "game_id") and hasattr(obj, "player_list") and hasattr(obj, "monster_grid"): + ca_raw = getattr(obj, "concurrent_action", None) + ca_enc = ca_raw + if isinstance(ca_raw, dict) and not (ca_raw.get("pending") or []): + ca_enc = None + return { + "game_id": obj.game_id, + "player_list": obj.player_list, + "monster_grid": obj.monster_grid, + "citizen_grid": obj.citizen_grid, + "domain_grid": obj.domain_grid, + "die_one": obj.die_one, + "die_two": obj.die_two, + "die_sum": obj.die_sum, + "rolled_die_one": getattr(obj, "rolled_die_one", obj.die_one), + "rolled_die_two": getattr(obj, "rolled_die_two", obj.die_two), + "rolled_die_sum": getattr(obj, "rolled_die_sum", obj.die_sum), + "pending_roll": getattr(obj, "pending_roll", None), + "exhausted_count": obj.exhausted_count, + "effects": obj.effects, + "action_required": obj.action_required, + "pending_required_choice": getattr(obj, "pending_required_choice", None), + "pending_action_end_queue": getattr(obj, "pending_action_end_queue", None) or [], + "concurrent_action": ca_enc, + "tick_id": getattr(obj, "tick_id", 0), + "turn_number": getattr(obj, "turn_number", 1), + "turn_index": getattr(obj, "turn_index", 0), + "phase": getattr(obj, "phase", "roll"), + "actions_remaining": getattr(obj, "actions_remaining", 0), + "active_player_id": obj.current_player_id() if hasattr(obj, "current_player_id") else None, + "harvest_player_order": getattr(obj, "harvest_player_order", None), + "harvest_player_idx": getattr(obj, "harvest_player_idx", 0), + "harvest_consumed": getattr(obj, "harvest_consumed", {}) or {}, + "harvest_prompt_slots": obj.harvest_slots_for_api() if hasattr(obj, "harvest_slots_for_api") else [], + "game_log": list(getattr(obj, "game_log", None) or []), + } + return super().default(obj) diff --git a/game_setup.py b/game_setup.py new file mode 100644 index 0000000..00840c7 --- /dev/null +++ b/game_setup.py @@ -0,0 +1,283 @@ +import random +from typing import List + +from cards import Citizen, Domain, Duke, Monster, Starter +from game_models import Player + + +def load_game_data(game_id, preset, player_list_from_lobby, debug_starting_resources=False): + import mariadb + + monster_query = "" + monster_stack = [] + citizen_query = "" + citizen_stack = [] + domain_query = "select_random_domains" + domain_stack = [] + duke_query = "select_random_dukes" + duke_stack = [] + starter_query = "SELECT * FROM starters" + starter_stack = [] + player_list = [] + citizen_grid: List[List[Citizen]] = [[] for _ in range(10)] + domain_grid: List[List[Domain]] = [[] for _ in range(5)] + monster_grid: List[List[Monster]] = [[] for _ in range(5)] + die_one = 0 + die_two = 0 + die_sum = 0 + exhausted_count = 0 + effects = { + "roll_phase": [], + "harvest_phase": [], + "action_phase": [], + } + action_required = { + "id": "", + "action": "", + } + tick_id = 0 + turn_number = 1 + turn_index = 0 + # Start in setup; if no setup actions are needed the engine will advance into roll. + phase = "setup" + actions_remaining = 0 + harvest_processed = False + pending_harvest_choices = [] + match preset: + case "base1": + monster_query = "select_base1_monsters" + citizen_query = "select_base1_citizens" + case "base2": + monster_query = "select_base2_monsters" + citizen_query = "select_base2_citizens" + try: + my_connect = mariadb.connect( + user="vckonline", password="vckonline", host="127.0.0.1", database="vckonline", port=3306 + ) + my_cursor = my_connect.cursor(dictionary=True) + + my_cursor.callproc(monster_query) + + results = my_cursor.fetchall() + for row in results: + my_monster = Monster( + row["id_monsters"], + row["name"], + row["area"], + row["monster_type"], + row["monster_order"], + row["strength_cost"], + row["magic_cost"], + row["vp_reward"], + row["gold_reward"], + row["strength_reward"], + row["magic_reward"], + row["has_special_reward"], + row["special_reward"], + row["has_special_cost"], + row["special_cost"], + row["is_extra"], + row["expansion"], + ) + monster_stack.append(my_monster) + + my_cursor.callproc(citizen_query) + citizen_count = 5 + if len(player_list_from_lobby) == 5: + citizen_count = 6 + results = my_cursor.fetchall() + for row in results: + for _ in range(citizen_count): + my_citizen = Citizen( + row["id_citizens"], + row["name"], + row["gold_cost"], + row["roll_match1"], + row["roll_match2"], + row["shadow_count"], + row["holy_count"], + row["soldier_count"], + row["worker_count"], + row["gold_payout_on_turn"], + row["gold_payout_off_turn"], + row["strength_payout_on_turn"], + row["strength_payout_off_turn"], + row["magic_payout_on_turn"], + row["magic_payout_off_turn"], + row["has_special_payout_on_turn"], + row["has_special_payout_off_turn"], + row["special_payout_on_turn"], + row["special_payout_off_turn"], + row["special_citizen"], + row["expansion"], + ) + citizen_stack.append(my_citizen) + + my_cursor.callproc(domain_query) + results = my_cursor.fetchall() + for row in results: + my_domain = Domain( + row["id_domains"], + row["name"], + row["gold_cost"], + row["shadow_count"], + row["holy_count"], + row["soldier_count"], + row["worker_count"], + row["vp_reward"], + row["has_activation_effect"], + row["has_passive_effect"], + row["passive_effect"], + row["activation_effect"], + row["text"], + row["expansion"], + ) + domain_stack.append(my_domain) + + my_cursor.callproc(duke_query) + results = my_cursor.fetchall() + for row in results: + my_duke = Duke( + row["id_dukes"], + row["name"], + row["gold_mult"], + row["strength_mult"], + row["magic_mult"], + row["shadow_mult"], + row["holy_mult"], + row["soldier_mult"], + row["worker_mult"], + row["monster_mult"], + row["citizen_mult"], + row["domain_mult"], + row["boss_mult"], + row["minion_mult"], + row["beast_mult"], + row["titan_mult"], + row["expansion"], + ) + duke_stack.append(my_duke) + + my_cursor.execute(starter_query) + my_result = my_cursor.fetchall() + for row in my_result: + my_starter = Starter( + row["id_starters"], + row["name"], + row["roll_match1"], + row["roll_match2"], + row["gold_payout_on_turn"], + row["gold_payout_off_turn"], + row["strength_payout_on_turn"], + row["strength_payout_off_turn"], + row["magic_payout_on_turn"], + row["magic_payout_off_turn"], + row["has_special_payout_on_turn"], + row["has_special_payout_off_turn"], + row["special_payout_on_turn"], + row["special_payout_off_turn"], + row["expansion"], + ) + starter_stack.append(my_starter) + my_cursor.close() + my_connect.close() + except Exception as e: + print(f"Error: {e}") + # create players and determine order + if not all([player_list_from_lobby, starter_query, monster_stack, citizen_stack, domain_stack, duke_stack]): + raise ValueError("One or more required lists are empty.") + else: + for player in player_list_from_lobby: + my_player = Player(player.player_id, player.name) + if debug_starting_resources: + my_player.gold_score = 100 + my_player.strength_score = 100 + my_player.magic_score = 100 + player_list.append(my_player) + random.shuffle(player_list) + player_list[0].is_first = True + # give players starters and dukes + for player in player_list: + player.owned_starters.append(starter_stack[0]) + player.owned_starters.append(starter_stack[1]) + for _ in range(2): + player.owned_dukes.append(duke_stack.pop()) + # deal monsters onto the board + grouped_monsters = {} + for monster in monster_stack: + area = monster.area + if area in grouped_monsters: + grouped_monsters[area].append(monster) + else: + grouped_monsters[area] = [monster] + for area, monsters in grouped_monsters.items(): + monsters.sort(key=lambda item: item.order, reverse=True) + areas = list(grouped_monsters.keys()) + chosen_areas = random.sample(areas, 5) + for i, area in enumerate(chosen_areas): + monsters = grouped_monsters[area] + monster_grid[i].extend(monsters) + for stack in monster_grid: + for monster in stack: + monster.toggle_visibility(True) + stack[-1].toggle_accessibility(True) + # deal citizens onto the board + citizens_by_roll = {roll: [] for roll in [1, 2, 3, 4, 5, 6, 7, 8, 9, 11]} + for citizen in citizen_stack: + citizen.toggle_visibility() + citizens_by_roll[citizen.roll_match1].append(citizen) + for roll in citizens_by_roll: + index = roll - 1 if roll < 11 else 9 + citizens = citizens_by_roll[roll] + citizen_grid[index].extend(list(citizens)) + citizen_grid[index][-1].toggle_accessibility(True) + if debug_starting_resources: + for player in player_list: + for stack in citizen_grid: + c = stack.pop(-1) + c.is_flipped = False + c.toggle_visibility(True) + c.toggle_accessibility(True) + player.owned_citizens.append(c) + if stack: + stack[-1].toggle_accessibility(True) + # deal the domains into stacks + for i in range(5): + stack = domain_grid[i] + for j in range(3): + if j == 2: + domain = domain_stack.pop() + domain.toggle_visibility(True) + domain.toggle_accessibility(True) + stack.append(domain) + else: + domain = domain_stack.pop() + stack.append(domain) + + game_state = { + "game_id": game_id, + "player_list": player_list, + "monster_grid": monster_grid, + "citizen_grid": citizen_grid, + "domain_grid": domain_grid, + "die_one": die_one, + "die_two": die_two, + "die_sum": die_sum, + "exhausted_count": exhausted_count, + "effects": effects, + "action_required": action_required, + "concurrent_action": None, + "tick_id": tick_id, + "turn_number": turn_number, + "turn_index": turn_index, + "phase": phase, + "actions_remaining": actions_remaining, + "harvest_processed": harvest_processed, + "pending_harvest_choices": pending_harvest_choices, + "harvest_player_order": None, + "harvest_player_idx": 0, + "harvest_consumed": {}, + "game_log": [], + "pending_action_end_queue": [], + } + return game_state diff --git a/server.py b/server.py index ef6a530..fdb6754 100644 --- a/server.py +++ b/server.py @@ -46,6 +46,7 @@ class RenameRequest(BaseModel): class ReadyRequest(BaseModel): player_id: str + debug_starting_resources: bool = False class ResourcePayment(BaseModel): @@ -57,7 +58,7 @@ class ResourcePayment(BaseModel): class GameActionRequest(BaseModel): player_id: str - action_type: str # "hire_citizen", "buy_domain", "slay_monster", "take_resource", "act_on_required_action", "submit_concurrent_action" + action_type: str # "hire_citizen", "build_domain", "slay_monster", "take_resource", "act_on_required_action", "submit_concurrent_action" # Action parameters (varies by action type) citizen_id: Optional[int] = None domain_id: Optional[int] = None @@ -92,7 +93,7 @@ def resolve_action_payment(req: GameActionRequest): return 0, int(req.strength_cost or 0), int(req.magic_cost or 0) if req.action_type == "hire_citizen": return int(req.gold_cost or 0), 0, int(req.magic_cost or 0) - if req.action_type == "buy_domain": + if req.action_type == "build_domain": return int(req.gold_cost or 0), 0, int(req.magic_cost or 0) return int(req.gold_cost or 0), int(req.strength_cost or 0), int(req.magic_cost or 0) @@ -133,6 +134,7 @@ async def set_ready(request: ReadyRequest): for player in lobby: if player.player_id == request.player_id: player.is_ready = True + player.debug_starting_resources = bool(request.debug_starting_resources) player.last_active_time = time.time() # Check if all players are ready @@ -147,6 +149,7 @@ async def set_ready(request: ReadyRequest): gamer = GameMember(p.player_id, p.name, new_game_id) gamers.append(gamer) players_to_remove.append(p) + debug_starting_resources = any(bool(getattr(p, "debug_starting_resources", False)) for p in players_to_remove) # Remove ready players from lobby for p in players_to_remove: @@ -156,7 +159,12 @@ async def set_ready(request: ReadyRequest): try: # Get only the gamers for this new game game_gamers = [g for g in gamers if g.game_id == new_game_id] - game_state = load_game_data(new_game_id, "base1", game_gamers) + game_state = load_game_data( + new_game_id, + "base1", + game_gamers, + debug_starting_resources=debug_starting_resources, + ) new_game = Game(game_state) new_game.last_active_time = time.time() # Auto-run the start-of-game roll/harvest so the first state is actionable. @@ -201,7 +209,8 @@ async def get_lobby_status(player_id: Optional[str] = None): lobby_data.append({ "player_id": member.player_id, "name": member.name, - "is_ready": member.is_ready + "is_ready": member.is_ready, + "debug_starting_resources": bool(getattr(member, "debug_starting_resources", False)), }) response = { @@ -266,7 +275,7 @@ async def get_game_state(game_id: str, player_id: Optional[str] = None): @app.post("/api/game/{game_id}/action") async def perform_game_action(game_id: str, request: GameActionRequest): - """Perform a game action (hire citizen, buy domain, slay monster, etc.)""" + """Perform a game action (hire citizen, build domain, slay monster, etc.)""" game = games.get(game_id) if not game: raise HTTPException(status_code=404, detail="Game not found") @@ -291,8 +300,8 @@ async def perform_game_action(game_id: str, request: GameActionRequest): _rollback_consumed_action(game) raise game.finish_turn_if_no_actions_remaining() - - elif request.action_type == "buy_domain": + + elif request.action_type == "build_domain": if request.domain_id is None: raise HTTPException(status_code=400, detail="domain_id required") if request.payment is None and request.gold_cost is None and request.magic_cost is None: @@ -301,7 +310,7 @@ async def perform_game_action(game_id: str, request: GameActionRequest): raise HTTPException(status_code=400, detail="Not your turn (or no actions remaining)") g, s, m = resolve_action_payment(request) try: - game.buy_domain(request.player_id, request.domain_id, g, m, s) + game.build_domain(request.player_id, request.domain_id, g, m, s) except ValueError as e: _rollback_consumed_action(game) raise HTTPException(status_code=400, detail=str(e)) @@ -458,19 +467,31 @@ async def root(): pre { background: #f5f5f5; padding: 10px; overflow-x: auto; } details { margin-top: 10px; } details > summary { cursor: pointer; font-weight: 700; user-select: none; } - .dice-row { display: flex; align-items: center; gap: 12px; margin: 10px 0; } + .dice-row { margin: 10px 0; } .dice { display: flex; gap: 10px; align-items: center; } - .dice-rig { + .dice-panel { margin-top: 8px; padding: 8px 10px; border: 1px solid #ddd; border-radius: 10px; background: #fff; } - .dice-rig label { font-size: 13px; } - .dice-rig-fields { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 6px; align-items: center; } - .dice-rig-fields input[type="number"] { width: 70px; } - .dice-rig-hint { margin-top: 6px; font-size: 12px; color: #444; } + .dice-panel-layout { + display: grid; + grid-template-columns: auto minmax(260px, 1fr) auto; + gap: 14px; + align-items: start; + } + .dice-panel-col { min-width: 0; } + .dice-panel label { font-size: 13px; } + .dice-panel-fields { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 6px; align-items: center; } + .dice-panel-fields input[type="number"] { width: 70px; } + .dice-panel-hint { margin-top: 6px; font-size: 12px; color: #444; } + .roll-effects { margin-top: 8px; font-size: 12px; color: #333; } + .roll-effects ul { margin: 4px 0 0 18px; padding: 0; } + @media (max-width: 980px) { + .dice-panel-layout { grid-template-columns: 1fr; } + } .die { width: 44px; height: 44px; border: 2px solid #222; @@ -482,7 +503,11 @@ async def root(): padding: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.08); } + .die.increase { border-color: #0a6; color: #0a6; } + .die.decrease { border-color: #b00; color: #b00; } .pip { width: 8px; height: 8px; border-radius: 50%; background: #111; justify-self: center; align-self: center; } + .die.increase .pip { background: currentColor; } + .die.decrease .pip { background: currentColor; } .pip.off { opacity: 0; } .dice-meta { color: #333; font-size: 14px; } .delta-wrap { display: flex; flex-wrap: wrap; gap: 10px; } @@ -615,11 +640,10 @@ async def root(): color: #fff; } - /* Payment editor (hire / buy / slay) */ + /* Payment editor (hire / build / slay) */ .pay-row { display: flex; align-items: flex-start; gap: 8px; flex-wrap: wrap; } .cost-line { flex: 1; min-width: 200px; } - .pay-controls { display: none; margin-top: 4px; } - .pay-controls.open { display: block; } + .pay-controls { display: block; margin-top: 4px; } .pay-controls input[type="number"] { width: 58px; } .game-log-wrap { @@ -659,36 +683,55 @@ async def root(): +
+ +
-
-

Game

-
-
-
-
+
+

Game

+
+
-
- -
- - +
+
+
+
+
+
+
+
+
+ +
+ + +
+
+
-
+
+ +
-

Game log

@@ -718,7 +761,49 @@ async def root(): let playerId = localStorage.getItem('playerId') || ''; let currentGameId = localStorage.getItem('gameId') || ''; let lastGameState = null; + let lastRenderedGameLogKey = null; let finalizeRollInFlight = false; + let autoHarvestInFlight = false; + + function getDebugStartingResourcesEnabled() { + return localStorage.getItem('debugStartingResourcesEnabled') === 'true'; + } + + function getAutoHarvestEnabled() { + const v = localStorage.getItem('autoHarvestEnabled'); + if (v === null) return true; + return v === 'true'; + } + + function syncDebugStartingResourcesUiFromStorage() { + const el = document.getElementById('debugStartingResourcesEnabled'); + if (!el) return; + el.checked = getDebugStartingResourcesEnabled(); + } + + function wireDebugStartingResourcesUi() { + const el = document.getElementById('debugStartingResourcesEnabled'); + if (!el) return; + el.addEventListener('change', () => { + localStorage.setItem('debugStartingResourcesEnabled', String(!!el.checked)); + }); + syncDebugStartingResourcesUiFromStorage(); + } + + function syncAutoHarvestUiFromStorage() { + const el = document.getElementById('autoHarvestEnabled'); + if (!el) return; + el.checked = getAutoHarvestEnabled(); + } + + function wireAutoHarvestUi() { + const el = document.getElementById('autoHarvestEnabled'); + if (!el) return; + el.addEventListener('change', () => { + localStorage.setItem('autoHarvestEnabled', String(!!el.checked)); + }); + syncAutoHarvestUiFromStorage(); + } function clampDie(n) { const x = Number(n); @@ -740,9 +825,9 @@ async def root(): } function syncDiceRigUiFromStorage() { - const enabledEl = document.getElementById('rigEnabled'); - const d1El = document.getElementById('rigDie1'); - const d2El = document.getElementById('rigDie2'); + const enabledEl = document.getElementById('diceOverrideEnabled'); + const d1El = document.getElementById('diceOverrideDie1'); + const d2El = document.getElementById('diceOverrideDie2'); if (!enabledEl || !d1El || !d2El) return; const s = getDiceRigSettings(); enabledEl.checked = !!s.enabled; @@ -753,9 +838,9 @@ async def root(): } function wireDiceRigUi() { - const enabledEl = document.getElementById('rigEnabled'); - const d1El = document.getElementById('rigDie1'); - const d2El = document.getElementById('rigDie2'); + const enabledEl = document.getElementById('diceOverrideEnabled'); + const d1El = document.getElementById('diceOverrideDie1'); + const d2El = document.getElementById('diceOverrideDie2'); if (!enabledEl || !d1El || !d2El) return; const onChange = () => { @@ -788,6 +873,8 @@ async def root(): document.getElementById('playerId').textContent = 'Player ID: ' + playerId; } wireDiceRigUi(); + wireDebugStartingResourcesUi(); + wireAutoHarvestUi(); async function joinLobby() { const name = document.getElementById('playerName').value; @@ -819,8 +906,9 @@ async def root(): let html = '

Players in Lobby:

'; data.lobby.forEach(p => { + const debugTag = p.debug_starting_resources ? ' (debug 100/100/100)' : ''; html += `
- ${p.name} - ${p.is_ready ? 'Ready' : 'Not Ready'} + ${p.name}${debugTag} - ${p.is_ready ? 'Ready' : 'Not Ready'} ${p.player_id === playerId ? '' : ''}
`; }); @@ -853,7 +941,10 @@ async def root(): const response = await fetch('/api/lobby/ready', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({player_id: playerId}) + body: JSON.stringify({ + player_id: playerId, + debug_starting_resources: getDebugStartingResourcesEnabled(), + }) }); const data = await response.json(); if (data.game_id) { @@ -879,6 +970,27 @@ async def root(): updateConcurrentPolling(data); updatePassiveGamePolling(data); refreshTableauActionButtons(data); + maybeAutoHarvest(data); + } + + function maybeAutoHarvest(gameState) { + if (!playerId || !currentGameId) return; + if (!gameState || autoHarvestInFlight) return; + if (!getAutoHarvestEnabled()) return; + const concurrent = gameState?.concurrent_action || null; + if (concurrent && Array.isArray(concurrent.pending) && concurrent.pending.length > 0) return; + const req = gameState?.action_required || {}; + const reqId = (req?.id || '').toString(); + const reqAction = (req?.action || '').toString(); + if (reqAction !== 'manual_harvest') return; + if (!reqId || reqId !== playerId) return; + const slots = Array.isArray(gameState?.harvest_prompt_slots) ? gameState.harvest_prompt_slots : []; + if (slots.length !== 1) return; + const slotKey = (slots[0]?.slot_key || '').toString().trim(); + if (!slotKey) return; + autoHarvestInFlight = true; + sendHarvestCard(slotKey, { suppressAlert: true }) + .finally(() => { autoHarvestInFlight = false; }); } async function maybeFinalizePendingRoll(gameState) { @@ -897,6 +1009,16 @@ async def root(): const rolled1 = clampDie(gameState.rolled_die_one ?? gameState.die_one ?? 1); const rolled2 = clampDie(gameState.rolled_die_two ?? gameState.die_two ?? 1); const s = getDiceRigSettings(); + if (!s.enabled) { + const player = Array.isArray(gameState?.player_list) + ? gameState.player_list.find(p => (p?.player_id || '') === playerId) + : null; + const opts = listRollSetOneDieOptions(player, rolled1, rolled2, gameState.turn_number); + if (opts.length > 0) { + // Do not auto-finalize when roll modifiers are available. + return; + } + } const final1 = s.enabled ? clampDie(s.d1) : rolled1; const final2 = s.enabled ? clampDie(s.d2) : rolled2; @@ -929,6 +1051,123 @@ async def root(): } } + function ownedCitizenRoleSelectorCount(player, roleSelector) { + const role = (roleSelector || '').toString().trim().toLowerCase(); + if (!role) return 0; + const citizens = Array.isArray(player?.owned_citizens) ? player.owned_citizens : []; + const keyByRole = { + holy_citizen: 'holy_count', + shadow_citizen: 'shadow_count', + soldier_citizen: 'soldier_count', + worker_citizen: 'worker_count', + }; + const key = keyByRole[role]; + if (!key) return 0; + let n = 0; + citizens.forEach((c) => { + if (Number(c?.[key] || 0) > 0) n += 1; + }); + return n; + } + + function domainPassiveOnBuildTurnCooldown(domain, turnNumber) { + const acq = domain?.acquired_turn_number; + if (acq === undefined || acq === null) return false; + const t = Number(turnNumber); + if (!Number.isFinite(t)) return false; + return Number(acq) === t; + } + + function parseRollSetOneDieEffects(player, turnNumber) { + const out = []; + const domains = Array.isArray(player?.owned_domains) ? player.owned_domains : []; + domains.forEach((d) => { + if (domainPassiveOnBuildTurnCooldown(d, turnNumber)) return; + const raw = (d?.passive_effect ?? '').toString().trim(); + if (!raw) return; + const parts = raw.split(/\\s+/); + const head0 = (parts[0] || '').toLowerCase().replace(/:/g, '.'); + if (!parts.length || head0 !== 'roll.set_one_die') return; + const kv = {}; + for (let i = 1; i < parts.length; i += 1) { + const p = parts[i]; + const eq = p.indexOf('='); + if (eq < 0) continue; + const k = p.slice(0, eq).trim().toLowerCase(); + const v = p.slice(eq + 1).trim(); + kv[k] = v; + } + const target = Number(kv.target); + const costSpec = (kv.cost || '').toString().trim().toLowerCase(); + if (!Number.isFinite(target) || target < 1 || target > 6 || !costSpec) return; + out.push({ domainName: (d?.name || 'Domain').toString(), target, costSpec }); + }); + return out; + } + + function rollEffectCostGold(player, costSpec) { + const spec = (costSpec || '').toString().trim().toLowerCase(); + if (spec.startsWith('g:')) { + const n = Number(spec.slice(2)); + if (!Number.isFinite(n) || n < 0) return null; + return Math.floor(n); + } + if (spec.startsWith('g_per_owned_role:')) { + const role = spec.slice('g_per_owned_role:'.length); + return ownedCitizenRoleSelectorCount(player, role); + } + if (spec === 'g:per_owned_holy_citizen' || spec === 'per_owned_holy_citizen') { + return ownedCitizenRoleSelectorCount(player, 'holy_citizen'); + } + return null; + } + + function listRollSetOneDieOptions(player, rolled1, rolled2, turnNumber) { + const effects = parseRollSetOneDieEffects(player, turnNumber); + const gold = Number(player?.gold_score || 0); + const options = []; + effects.forEach((e) => { + const costGold = rollEffectCostGold(player, e.costSpec); + if (costGold === null || gold < costGold) return; + if (Number(rolled1) !== Number(e.target)) { + options.push({ die: 1, target: Number(e.target), costGold, domainName: e.domainName }); + } + if (Number(rolled2) !== Number(e.target)) { + options.push({ die: 2, target: Number(e.target), costGold, domainName: e.domainName }); + } + }); + return options; + } + + async function sendFinalizeRollChoice(d1, d2) { + if (!playerId || !currentGameId) return; + if (finalizeRollInFlight) return; + finalizeRollInFlight = true; + try { + const res = await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'finalize_roll', + die_one: clampDie(d1), + die_two: clampDie(d2) + }) + }); + const payload = await res.json(); + if (!res.ok) { + alert(payload?.detail || 'Finalize roll failed'); + return; + } + if (payload && payload.game_state) applyGameStateClientUpdate(payload.game_state); + else getGameState(false); + } catch (e) { + console.error(e); + } finally { + finalizeRollInFlight = false; + } + } + function refreshTableauActionButtons(gameState) { const wrap = document.getElementById('tableauSeatLayout'); if (!wrap) return; @@ -1076,6 +1315,7 @@ async def root(): if (reqAction === 'bonus_resource_choice') return true; const trimmed = reqAction.trim(); if (trimmed.startsWith('choose ')) return true; + if (trimmed === 'choose_player' || trimmed === 'choose_monster_strength' || trimmed === 'domain_self_convert') return true; if (reqAction === 'standard_action' && (gameState.phase || '') === 'action') return true; return false; } @@ -1189,6 +1429,7 @@ async def root(): } const name = card.name || card.title || '(unnamed)'; const id = card.starter_id || card.citizen_id || card.monster_id || card.domain_id || card.duke_id || card.id || ''; + const isCitizen = card.citizen_id !== undefined && card.citizen_id !== null; const hints = []; if (card.roll_match1 !== undefined || card.roll_match2 !== undefined) { @@ -1200,6 +1441,7 @@ async def root(): if (card.strength_cost !== undefined) hints.push(`Strength cost: ${card.strength_cost}`); if (card.magic_cost !== undefined) hints.push(`Magic cost: ${card.magic_cost}`); pushHarvestHints(hints, card); + if (isCitizen && card.is_flipped) hints.push('Flipped — no harvest payout / roll spend counts'); const { sn, hn, son, wn } = citizenRoleCounts(card); const roleParts = []; @@ -1207,7 +1449,6 @@ async def root(): if (hn > 0) roleParts.push(`Holy +${hn}`); if (son > 0) roleParts.push(`Soldier +${son}`); if (wn > 0) roleParts.push(`Worker +${wn}`); - const isCitizen = card.citizen_id !== undefined && card.citizen_id !== null; const isDomain = card.domain_id !== undefined && card.domain_id !== null; const showRoleRow = (isCitizen || isDomain) && roleParts.length; const roleBlock = showRoleRow @@ -1232,7 +1473,9 @@ async def root(): if (!c || typeof c !== 'object') return; const name = (c.name || c.title || '').toString().trim(); const id = c.starter_id || c.citizen_id || c.monster_id || c.domain_id || c.duke_id || c.id || ''; - const key = `${name}||${id}`; + const isCitizenKey = c.citizen_id !== undefined && c.citizen_id !== null; + const flipSeg = isCitizenKey ? `||flip:${c.is_flipped ? 1 : 0}` : ''; + const key = `${name}||${id}${flipSeg}`; const cur = map.get(key); if (cur) cur.count += 1; else map.set(key, { card: c, count: 1, sortName: name.toLowerCase(), sortId: String(id) }); @@ -1276,6 +1519,17 @@ async def root(): // Otherwise synthesize from other special/effect fields we already serialize. const parts = []; + // Include baseline harvest payouts so market rows show core card behavior, + // not just special/passive text. + pushHarvestHints(parts, card); + // Monsters use reward fields instead of harvest payout fields. + if (card.monster_id !== undefined && card.monster_id !== null) { + const vp = Number(card.vp_reward || 0); + const gr = Number(card.gold_reward || 0); + const sr = Number(card.strength_reward || 0); + const mr = Number(card.magic_reward || 0); + parts.push(`Reward: VP ${vp} · G ${gr} · S ${sr} · M ${mr}`); + } const passive = (card.passive_effect ?? '').toString().trim(); const activation = (card.activation_effect ?? '').toString().trim(); @@ -1414,6 +1668,35 @@ async def root(): return bits.join(' · '); } + const expandedMonsterStacks = new Set(); + + function isMonsterStackExpanded(stackIndex) { + return expandedMonsterStacks.has(Number(stackIndex)); + } + + function toggleMonsterStackExpand(stackIndex) { + const idx = Number(stackIndex); + if (Number.isNaN(idx)) return; + if (expandedMonsterStacks.has(idx)) expandedMonsterStacks.delete(idx); + else expandedMonsterStacks.add(idx); + if (lastGameState) renderBoardTableau(lastGameState); + } + window.toggleMonsterStackExpand = toggleMonsterStackExpand; + + function renderMonsterStackCards(stack, stackIndex) { + const expanded = isMonsterStackExpanded(stackIndex); + const top = topOfStack(stack); + if (!top) return ''; + if (!expanded) { + return renderCardItem(top); + } + const cards = [...stack].reverse(); + return `
${cards.map((card, i) => { + const role = i === 0 ? 'Top (slayable)' : 'Buried'; + return `
${role}
${renderCardItem(card)}
`; + }).join('')}
`; + } + function renderBoardStackSection(title, grid, kind) { const g = Array.isArray(grid) ? grid : []; const blocks = g.map((stack, idx) => { @@ -1426,10 +1709,17 @@ async def root(): return `
Stack ${idx + 1}
empty
`; } const meta = boardStackMeta(kind, top, depth); + const expandControl = kind === 'monster' + ? `` + : ''; + const cardHtml = kind === 'monster' + ? renderMonsterStackCards(stack, idx) + : renderCardItem(top); return `
Stack ${idx + 1}
+ ${expandControl ? `
${expandControl}
` : ''}
${escapeHtml(meta)}
- ${renderCardItem(top)} + ${cardHtml}
`; }); return `

${escapeHtml(title)}

${blocks.join('')}
`; @@ -1444,7 +1734,7 @@ async def root(): const domainGrid = Array.isArray(gameState?.domain_grid) ? gameState.domain_grid : []; const monsterGrid = Array.isArray(gameState?.monster_grid) ? gameState.monster_grid : []; bodyEl.innerHTML = ` -
Top of each stack is the play surface; buried cards are not shown.
+
Top of each stack is the play surface. Monster stacks can be expanded to view buried cards, but only the top monster is slayable.
${renderBoardStackSection('Citizens (market)', citizenGrid, 'citizen')} ${renderBoardStackSection('Domains', domainGrid, 'domain')} @@ -1494,16 +1784,16 @@ async def root(): return masks[value] || []; } - function buildDie(value) { + function buildDie(value, status, title) { const die = document.createElement('div'); - die.className = 'die'; + die.className = 'die' + (status ? ` ${status}` : ''); const on = new Set(diePipMask(value)); for (let i = 0; i < 9; i++) { const pip = document.createElement('div'); pip.className = 'pip' + (on.has(i) ? '' : ' off'); die.appendChild(pip); } - die.title = `d${value || 0}`; + die.title = title || `d${value || 0}`; return die; } @@ -1511,6 +1801,20 @@ async def root(): const el = document.getElementById('gameLog'); if (!el) return; const entries = Array.isArray(gameState?.game_log) ? gameState.game_log : []; + const logKey = JSON.stringify([ + gameState?.game_id || currentGameId || '', + entries.map(e => [ + e && e.tick !== undefined && e.tick !== null ? e.tick : '', + (e && (e.msg || e.message)) || '' + ]) + ]); + if (logKey === lastRenderedGameLogKey) return; + + const wasAtBottom = (el.scrollHeight - el.scrollTop - el.clientHeight) < 8; + const previousScrollTop = el.scrollTop; + const shouldAutoScroll = lastRenderedGameLogKey === null || wasAtBottom; + lastRenderedGameLogKey = logKey; + if (!entries.length) { el.textContent = '(No events yet.)'; return; @@ -1520,7 +1824,7 @@ async def root(): const msg = escapeHtml(String((e && e.msg) || (e && e.message) || '')); return `
[${tick}]${msg}
`; }).join(''); - el.scrollTop = el.scrollHeight; + el.scrollTop = shouldAutoScroll ? el.scrollHeight : previousScrollTop; } function renderDice(gameState) { @@ -1533,12 +1837,20 @@ async def root(): const diceEl = document.getElementById('dice'); const metaEl = document.getElementById('diceMeta'); + const effectsEl = document.getElementById('rollEffects'); const deltaEl = document.getElementById('harvestDeltas'); if (!diceEl || !metaEl) return; + const die1Changed = rolled1 && final1 && rolled1 !== final1; + const die2Changed = rolled2 && final2 && rolled2 !== final2; + const die1Status = die1Changed ? (final1 > rolled1 ? 'increase' : 'decrease') : ''; + const die2Status = die2Changed ? (final2 > rolled2 ? 'increase' : 'decrease') : ''; + const die1Display = die1Changed ? final1 : rolled1; + const die2Display = die2Changed ? final2 : rolled2; + diceEl.innerHTML = ''; - diceEl.appendChild(buildDie(rolled1)); - diceEl.appendChild(buildDie(rolled2)); + diceEl.appendChild(buildDie(die1Display, die1Status, die1Changed ? `d${final1} (rolled ${rolled1})` : `d${die1Display}`)); + diceEl.appendChild(buildDie(die2Display, die2Status, die2Changed ? `d${final2} (rolled ${rolled2})` : `d${die2Display}`)); const turn = gameState?.turn_number; const phase = gameState?.phase; @@ -1554,6 +1866,9 @@ async def root(): if ((gameState?.phase || '') === 'roll_pending') { parts.push(`Awaiting finalize`); } + if ((gameState?.phase || '') === 'action_end_pending') { + parts.push(`Action-end domains`); + } if (turn !== undefined) parts.push(`Turn ${turn}`); if (phase) parts.push(`Phase ${phase}`); if (actionsRemaining !== undefined) parts.push(`Actions remaining ${actionsRemaining}`); @@ -1563,17 +1878,32 @@ async def root(): renderGameLog(gameState); // Update rig hint text. - const hintEl = document.getElementById('rigHint'); + const hintEl = document.getElementById('dicePanelHint'); if (hintEl) { const s = getDiceRigSettings(); const msg = s.enabled - ? `Enabled: will finalize as ${clampDie(s.d1)} + ${clampDie(s.d2)} (dice graphic still shows the real roll).` - : `Disabled: roll will be finalized as the real roll.`; + ? `Enabled: will finalize as ${clampDie(s.d1)} + ${clampDie(s.d2)} (graphic still shows the rolled dice).` + : `Disabled: roll finalizes as the rolled dice.`; hintEl.textContent = msg; } - if (!deltaEl) return; + if (effectsEl) { + const players = Array.isArray(gameState?.player_list) ? gameState.player_list : []; + const activePlayerId = (gameState?.active_player_id || '').toString(); + const activePlayer = players.find(p => (p?.player_id || '').toString() === activePlayerId) || null; + const effects = parseRollSetOneDieEffects(activePlayer, gameState.turn_number); + if (!effects.length) { + effectsEl.innerHTML = 'Roll phase effects: none'; + } else { + const rows = effects.map((e) => { + return `
  • ${escapeHtml(e.domainName)}: set one die to ${escapeHtml(String(e.target))} (cost: ${escapeHtml(e.costSpec)})
  • `; + }).join(''); + effectsEl.innerHTML = `Roll phase effects (active player):
      ${rows}
    `; + } + } + const players = Array.isArray(gameState?.player_list) ? gameState.player_list : []; + if (deltaEl) { deltaEl.innerHTML = ''; players.forEach(p => { const d = p?.harvest_delta || {}; @@ -1610,6 +1940,7 @@ async def root(): `; deltaEl.appendChild(card); }); + } renderChoicePanel(gameState); } @@ -1622,7 +1953,8 @@ async def root(): // turn-based action_required: while one is active the engine // will not advance and no per-player turn prompts are valid. const concurrent = gameState?.concurrent_action || null; - if (concurrent && Array.isArray(concurrent.pending)) { + const concurrentPending = concurrent && Array.isArray(concurrent.pending) ? concurrent.pending : []; + if (concurrentPending.length > 0) { return renderConcurrentActionPanel(gameState, concurrent); } @@ -1647,6 +1979,21 @@ async def root(): return; } + if (reqAction === 'finalize_roll') { + return renderFinalizeRollPrompt(gameState); + } + + if (reqAction === 'domain_self_convert') { + return renderDomainSelfConvertPrompt(gameState); + } + + if (reqAction === 'choose_player') { + return renderDomainChoosePlayer(gameState); + } + if (reqAction === 'choose_monster_strength') { + return renderDomainChooseMonster(gameState); + } + // Generic "choose ..." prompt from special payouts (e.g. "choose g 1 m 1") // Engine expects the response to be "choose 1"/"choose 2"/"choose 3". if (typeof reqAction === 'string' && reqAction.trim().startsWith('choose ')) { @@ -1746,6 +2093,10 @@ async def root(): if (t === 's') return 'Strength'; if (t === 'm') return 'Magic'; if (t === 'v') return 'Victory'; + if (t.startsWith('citizens.')) { + const name = t.split('.', 2)[1] || ''; + return name ? `${name} citizen` : 'Citizen'; + } return tok; } @@ -1761,7 +2112,7 @@ async def root(): const token = parts[i]; const amount = parts[i + 1]; const tl = (token || '').toString().trim().toLowerCase(); - if (!(tl === 'g' || tl === 's' || tl === 'm' || tl === 'v')) continue; + if (!(tl === 'g' || tl === 's' || tl === 'm' || tl === 'v' || tl.startsWith('citizens.'))) continue; options.push({ token, amount }); if (options.length >= 3) break; } @@ -1775,8 +2126,17 @@ async def root(): const req = gameState?.action_required || {}; const reqId = req?.id || ''; const isYou = (playerId && reqId === playerId); + const pendingChoice = gameState?.pending_required_choice || null; - const options = parseChooseCommand(chooseCmd); + let options = parseChooseCommand(chooseCmd); + if ( + pendingChoice && + pendingChoice.kind === 'special_payout_choose' && + Array.isArray(pendingChoice.options) && + pendingChoice.options.length + ) { + options = pendingChoice.options; + } if (!options.length) { panel.innerHTML = `
    Waiting on required action from ${reqId}: ${escapeHtml(chooseCmd)} @@ -1792,9 +2152,33 @@ async def root(): } const buttons = options.map((opt, idx) => { - const label = labelForChoiceToken(opt.token); + const token = (opt?.token || '').toString(); + const label = labelForChoiceToken(token); const amt = Number(opt.amount); const prettyAmt = Number.isFinite(amt) ? amt : opt.amount; + const isCitizen = token.trim().toLowerCase().startsWith('citizens.'); + if ((token || '').toString().trim().toLowerCase() === 'count_area') { + const area = (opt?.area ?? '').toString(); + const res = (opt?.resource ?? '').toString().toLowerCase(); + const mult = Number(opt?.mult); + const rLabel = labelForChoiceToken(res); + const mText = Number.isFinite(mult) ? mult : opt?.mult; + return ``; + } + if (isCitizen) { + const name = (opt?.name ?? '').toString().trim(); + const extras = Array.isArray(opt?.extras) ? opt.extras : []; + const extraText = extras.map(e => { + const et = (e?.token ?? '').toString().toLowerCase(); + const ea = Number(e?.amount); + const el = labelForChoiceToken(et); + const an = Number.isFinite(ea) ? ea : e?.amount; + return `+${an} ${el}`; + }).join(' + '); + const extraSuffix = extraText ? ` + ${extraText}` : ''; + const who = name ? `${name} citizen` : label; + return ``; + } return ``; }).join(''); @@ -1808,6 +2192,50 @@ async def root(): `; } + function renderFinalizeRollPrompt(gameState) { + const panel = document.getElementById('choicePanel'); + if (!panel) return; + const req = gameState?.action_required || {}; + const reqId = (req?.id || '').toString(); + const isYou = (playerId && reqId === playerId); + const rolled1 = clampDie(gameState?.rolled_die_one ?? gameState?.die_one ?? 1); + const rolled2 = clampDie(gameState?.rolled_die_two ?? gameState?.die_two ?? 1); + + if (!isYou) { + panel.innerHTML = `
    + Waiting on required action from ${escapeHtml(reqId)}: finalize_roll +
    `; + return; + } + + const player = Array.isArray(gameState?.player_list) + ? gameState.player_list.find(p => (p?.player_id || '') === playerId) + : null; + const options = listRollSetOneDieOptions(player, rolled1, rolled2, gameState.turn_number); + const keepBtn = ``; + const modBtns = options.map((o) => { + const fromVal = (o.die === 1) ? rolled1 : rolled2; + const d1 = (o.die === 1) ? o.target : rolled1; + const d2 = (o.die === 2) ? o.target : rolled2; + return ``; + }).join(' '); + const note = options.length + ? '
    Choose a roll modifier or keep the rolled dice.
    ' + : '
    No roll modifiers available; finalize to continue.
    '; + + panel.innerHTML = ` +
    +
    Finalize Roll
    +
    Rolled: ${rolled1} + ${rolled2}
    +
    + ${keepBtn} + ${modBtns} +
    + ${note} +
    + `; + } + async function sendChooseIndex(n) { if (!playerId || !currentGameId) return; try { @@ -1826,6 +2254,232 @@ async def root(): } } + async function sendChoosePlayerIndex(n) { + if (!playerId || !currentGameId) return; + try { + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'act_on_required_action', + action: `choose_player ${Number(n)}` + }) + }); + getGameState(false); + } catch (e) { + console.error(e); + } + } + + async function sendChooseMonsterIndex(n) { + if (!playerId || !currentGameId) return; + try { + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'act_on_required_action', + action: `choose_monster ${Number(n)}` + }) + }); + getGameState(false); + } catch (e) { + console.error(e); + } + } + + async function sendDomainManipulateSkip() { + if (!playerId || !currentGameId) return; + try { + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'act_on_required_action', + action: 'skip' + }) + }); + getGameState(false); + } catch (e) { + console.error(e); + } + } + + function resourceSpecLabel(spec) { + const raw = (spec || '').toString().trim().toLowerCase(); + const m = /^(g|s|m|v|vp)\\s*:\\s*(\\d+)$/.exec(raw); + if (!m) return raw || ''; + const n = Number(m[2]); + const k = m[1] === 'vp' ? 'v' : m[1]; + const word = k === 'g' ? 'gold' : k === 's' ? 'strength' : k === 'm' ? 'magic' : 'VP'; + const unit = k === 'v' ? '' : ' '; + return k === 'v' ? `${n} VP` : `${n}${unit}${word}`; + } + + function domainEffectGainIsVp(kv) { + const g = (kv?.gain ?? '').toString().trim().toLowerCase(); + return g.startsWith('v:') || g.startsWith('vp:'); + } + + function domainManipulateExplain(prc) { + const item = prc?.item || {}; + const mode = (item.mode || '').toString().trim().toLowerCase(); + const kv = item.kv || {}; + if (mode === 'pay_to_player') { + const pay = resourceSpecLabel(kv.pay); + const gain = resourceSpecLabel(kv.gain); + const gainLine = gain ? ` Gain ${gain} from the bank (not from that player).` : ''; + let decline = ''; + if (prc?.allow_skip && domainEffectGainIsVp(kv)) { + decline = ' You may decline: no payment and no VP.'; + } else if (prc?.allow_skip) { + decline = ' You may skip this optional effect.'; + } + return `Pay ${pay || '(see rules)'} to the player you choose.${gainLine}${decline}`; + } + if (mode === 'take_from_player') { + const take = resourceSpecLabel(kv.take); + return `Take ${take || '(see rules)'} from the player you choose.`; + } + return 'Choose another player.'; + } + + function selfConvertExplain(kv) { + const pay = resourceSpecLabel(kv?.pay); + const gain = resourceSpecLabel(kv?.gain); + return `Trade ${pay || '?'} from your supply for ${gain || '?'} (bank).`; + } + + function renderDomainSelfConvertPrompt(gameState) { + const panel = document.getElementById('choicePanel'); + if (!panel) return; + const req = gameState?.action_required || {}; + const reqId = (req?.id || '').toString(); + const isYou = (playerId && reqId === playerId); + const prc = gameState?.pending_required_choice || null; + const dn = (prc?.domain_name || 'Domain').toString(); + const kv = prc?.kv || {}; + const explain = selfConvertExplain(kv); + if (!isYou) { + panel.innerHTML = `
    + Waiting on ${escapeHtml(reqId)} for ${escapeHtml(dn)} optional activation trade. +
    `; + return; + } + panel.innerHTML = ` +
    +
    ${escapeHtml(dn)}: optional trade
    +
    ${escapeHtml(explain)}
    +
    + + +
    +
    `; + } + + async function sendSelfConvertConfirm() { + if (!playerId || !currentGameId) return; + try { + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'act_on_required_action', + action: 'confirm_self_convert' + }) + }); + getGameState(false); + } catch (e) { + console.error(e); + } + } + + async function sendSelfConvertDecline() { + if (!playerId || !currentGameId) return; + try { + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'act_on_required_action', + action: 'skip' + }) + }); + getGameState(false); + } catch (e) { + console.error(e); + } + } + + function renderDomainChoosePlayer(gameState) { + const panel = document.getElementById('choicePanel'); + if (!panel) return; + const req = gameState?.action_required || {}; + const reqId = (req?.id || '').toString(); + const isYou = (playerId && reqId === playerId); + const prc = gameState?.pending_required_choice || null; + const opts = Array.isArray(prc?.options) ? prc.options : []; + const dn = (prc?.item?.domain_name || 'Domain').toString(); + const explain = prc?.kind === 'domain_manipulate_player' + ? domainManipulateExplain(prc) + : 'Choose another player.'; + if (!isYou) { + panel.innerHTML = `
    + Waiting on ${escapeHtml(reqId)} to choose a player for ${escapeHtml(dn)}. +
    `; + return; + } + const kv = prc?.item?.kv || {}; + const skipLabel = (prc?.allow_skip && domainEffectGainIsVp(kv)) + ? 'Decline (no pay, no VP)' + : 'Skip (optional)'; + const skipBtn = prc?.allow_skip + ? `` + : ''; + const btns = opts.map((o, idx) => { + const nm = escapeHtml((o?.name || o?.player_id || '?').toString()); + return ``; + }).join(' '); + panel.innerHTML = ` +
    +
    ${escapeHtml(dn)}: choose another player
    +
    ${escapeHtml(explain)}
    +
    ${btns}${skipBtn}
    +
    `; + } + + function renderDomainChooseMonster(gameState) { + const panel = document.getElementById('choicePanel'); + if (!panel) return; + const req = gameState?.action_required || {}; + const reqId = (req?.id || '').toString(); + const isYou = (playerId && reqId === playerId); + const prc = gameState?.pending_required_choice || null; + const opts = Array.isArray(prc?.options) ? prc.options : []; + const dn = (prc?.domain_name || 'Domain').toString(); + const delta = Number(prc?.delta) || 0; + if (!isYou) { + panel.innerHTML = `
    + Waiting on ${escapeHtml(reqId)} for ${escapeHtml(dn)} (monster +${delta} strength cost). +
    `; + return; + } + const btns = opts.map((o, idx) => { + const nm = escapeHtml((o?.name || '?').toString()); + return ``; + }).join(' '); + panel.innerHTML = ` +
    +
    ${escapeHtml(dn)}: add +${delta} to a center monster strength cost
    +
    ${btns}
    +
    `; + } + // Concurrent (non-ordered) prompt rendering. // // The server exposes `concurrent_action = { kind, pending, completed, ... }`. @@ -1835,6 +2489,7 @@ async def root(): // CONCURRENT_RENDERERS keyed on the same `kind` used server-side. const CONCURRENT_RENDERERS = { choose_duke: renderChooseDukeConcurrent, + flip_one_citizen: renderFlipOneCitizenConcurrent, }; function renderConcurrentActionPanel(gameState, concurrent) { @@ -1921,6 +2576,78 @@ async def root(): `; } + function renderFlipOneCitizenConcurrent(gameState, concurrent) { + const panel = document.getElementById('choicePanel'); + if (!panel) return; + + const pending = Array.isArray(concurrent.pending) ? concurrent.pending : []; + const completed = Array.isArray(concurrent.completed) ? concurrent.completed : []; + const isPending = !!(playerId && pending.includes(playerId)); + const totalParticipants = pending.length + completed.length; + const data = concurrent.data || {}; + const buyerId = (data.buyer_id || '').toString(); + + const players = Array.isArray(gameState?.player_list) ? gameState.player_list : []; + const buyer = players.find(p => (p?.player_id || '') === buyerId) || null; + const buyerTag = buyer?.name ? `${escapeHtml(buyer.name)}` : (buyerId ? `${escapeHtml(buyerId)}` : ''); + const you = players.find(p => p?.player_id === playerId) || null; + const waitingLabels = pendingPlayerLabels(gameState, pending); + + const statusLine = `
    + Cursed Cavern — flip one citizen face-down: ${completed.length}/${totalParticipants} player choice(s) submitted. + ${pending.length ? `Waiting on: ${escapeHtml(waitingLabels.join(', '))}.` : ''} + ${buyerTag ? `
    Triggered by ${buyerTag}.
    ` : ''} +
    `; + + if (!isPending) { + const youDone = !!(playerId && completed.includes(playerId)); + const yourLine = youDone + ? `
    You already chose a citizen to flip. Waiting on other players.
    ` + : `
    You have no pending flip choice (no eligible citizens, or not in this prompt).
    `; + panel.innerHTML = `
    + ${statusLine}${yourLine} +
    `; + return; + } + + const citizens = Array.isArray(you?.owned_citizens) ? you.owned_citizens : []; + const choices = []; + citizens.forEach((c, idx) => { + if (!c || c.is_flipped) return; + const nm = (c.name || `Citizen #${idx}`).toString(); + choices.push({ idx, card: c, nm }); + }); + + if (!choices.length) { + panel.innerHTML = `
    + ${statusLine}
    No face-up citizens on your tableau — contact host if this seems wrong.
    +
    `; + return; + } + + const buttons = choices.map(({ idx, card, nm }) => { + const rm = card.roll_match1 !== undefined || card.roll_match2 !== undefined + ? ` · Roll ${card.roll_match1 ?? ''}/${card.roll_match2 ?? ''}` + : ''; + const gc = card.gold_cost !== undefined ? ` · Cost ${card.gold_cost}g` : ''; + return `
    +
    ${escapeHtml(nm)} (slot #${idx})
    +
    ${escapeHtml(rm + gc)}
    +
    + +
    +
    `; + }).join(''); + + panel.innerHTML = ` +
    + ${statusLine} +
    Choose 1 citizen to flip face-down
    +
    ${buttons}
    +
    + `; + } + async function submitConcurrentAction(kind, response) { if (!playerId || !currentGameId) return; try { @@ -1989,6 +2716,40 @@ async def root(): return n; } + function normalizedPassiveEffects(player, turnNumber) { + const out = []; + const domains = Array.isArray(player?.owned_domains) ? player.owned_domains : []; + domains.forEach((d) => { + if (domainPassiveOnBuildTurnCooldown(d, turnNumber)) return; + const name = (d?.name ?? '').toString().trim().toLowerCase(); + const text = (d?.text ?? '').toString().trim().toLowerCase(); + const raw = (d?.passive_effect ?? '').toString().trim().toLowerCase(); + if (raw) { + out.push(raw); + const nrm = raw.replace(/effect:add/g, 'effect.add').replace(/action:/g, 'action.'); + if (nrm.startsWith('effect.add ')) { + out.push(nrm.slice('effect.add '.length).trim()); + } + } + // Backward-compatibility for seed data where passive_effect is NULL + // and behavior only exists in human-readable card text. + if (name.includes('emerald stronghold') || (text.includes("ignore '+'") && text.includes('buying citizens'))) { + out.push('action.emeraldstronghold'); + } + if (name.includes("pratchett") || (text.includes('1gp less') && text.includes('domain'))) { + out.push('action.pratchettsplateau'); + } + }); + return out; + } + + function hasActionEffectFlag(player, flag, turnNumber) { + const target = (flag ?? '').toString().trim().toLowerCase(); + if (!target) return false; + const effects = normalizedPassiveEffects(player, turnNumber); + return effects.includes(target); + } + function clampPayInt(value, minV, maxV) { let n = Math.floor(Number(value)); if (!Number.isFinite(n)) n = 0; @@ -2010,18 +2771,58 @@ async def root(): return { gold: g, strength: s, magic: m }; } - function bindPayCostToggles(panel) { - if (!panel) return; - panel.querySelectorAll('.pay-cost-toggle').forEach((el) => { - el.onclick = function (e) { - e.preventDefault(); - const key = el.getAttribute('data-pay-key'); - if (!key) return; - const box = document.getElementById('pay-editor-' + key); - if (!box) return; - box.classList.toggle('open'); + function capturePayEditorRenderState(panel) { + const state = {}; + if (!panel) return state; + const active = document.activeElement; + panel.querySelectorAll('.pay-cost-key').forEach((el) => { + const key = el.getAttribute('data-pay-key'); + if (!key) return; + const row = el.closest('.pay-row'); + const box = document.getElementById('pay-editor-' + key); + if (!row || !box) return; + const gEl = row.querySelector('.pay-g'); + const sEl = row.querySelector('.pay-s'); + const mEl = row.querySelector('.pay-m'); + const entry = { + gold: gEl ? gEl.value : '', + strength: sEl ? sEl.value : '', + magic: mEl ? mEl.value : '', + focusClass: '', }; + if (active && row.contains(active)) { + if (active.classList.contains('pay-g')) entry.focusClass = 'pay-g'; + else if (active.classList.contains('pay-s')) entry.focusClass = 'pay-s'; + else if (active.classList.contains('pay-m')) entry.focusClass = 'pay-m'; + } + state[key] = entry; }); + return state; + } + + function restorePayEditorRenderState(panel, state) { + if (!panel || !state) return; + let focusTarget = null; + panel.querySelectorAll('.pay-cost-key').forEach((el) => { + const key = el.getAttribute('data-pay-key'); + const entry = key ? state[key] : null; + if (!entry) return; + const row = el.closest('.pay-row'); + const box = document.getElementById('pay-editor-' + key); + if (!row || !box) return; + const gEl = row.querySelector('.pay-g'); + const sEl = row.querySelector('.pay-s'); + const mEl = row.querySelector('.pay-m'); + if (gEl) gEl.value = clampPayInt(entry.gold, gEl.min, gEl.max); + if (sEl) sEl.value = clampPayInt(entry.strength, sEl.min, sEl.max); + if (mEl) mEl.value = clampPayInt(entry.magic, mEl.min, mEl.max); + if (entry.focusClass) { + focusTarget = row.querySelector('.' + entry.focusClass); + } + }); + if (focusTarget) { + focusTarget.focus(); + } } async function hireCitizenFromRow(btn) { @@ -2041,7 +2842,7 @@ async def root(): getGameState(false); } - async function buyDomainFromRow(btn) { + async function buildDomainFromRow(btn) { const row = btn.closest('.pay-row'); if (!row || !playerId || !currentGameId) return; const p = readPayRow(row); @@ -2050,7 +2851,7 @@ async def root(): headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ player_id: playerId, - action_type: 'buy_domain', + action_type: 'build_domain', domain_id: Number(row.dataset.domainId), payment: { gold: p.gold, strength: p.strength, magic: p.magic } }) @@ -2078,6 +2879,7 @@ async def root(): function renderStandardActionPanel(gameState) { const panel = document.getElementById('choicePanel'); if (!panel) return; + const payEditorState = capturePayEditorRenderState(panel); const req = gameState?.action_required || {}; const reqId = req?.id || ''; @@ -2099,6 +2901,9 @@ async def root(): const S = Number(p?.strength_score || 0); const M = Number(p?.magic_score || 0); const V = Number(p?.victory_score || 0); + const tn = Number(gameState?.turn_number); + const emeraldActive = hasActionEffectFlag(p, 'action.emeraldstronghold', tn); + const pratchettActive = hasActionEffectFlag(p, 'action.pratchettsplateau', tn); const affordCitizens = []; const affordDomains = []; @@ -2110,12 +2915,12 @@ async def root(): const top = topOfStack(stack); if (!top) return; const baseCost = Number(top.gold_cost || 0); - const surcharge = ownedNameCount(p, top.name); + const surcharge = emeraldActive ? 0 : ownedNameCount(p, top.name); const scaledCost = baseCost + surcharge; const evalRes = canAffordCost(p, { gold: scaledCost, strength: 0, magicMin: 0 }); console.log('[AFFORD_CHECK] citizen', { stackIndex: idx, stackSize: stack?.length || 0, card: top, player: { G, S, M, V }, eval: evalRes }); if (top.is_accessible && evalRes.ok) { - affordCitizens.push({ card: top, stackIndex: idx, stackSize: stack.length, pay: evalRes, scaledCost, surcharge, baseCost }); + affordCitizens.push({ card: top, stackIndex: idx, stackSize: stack.length, pay: evalRes, scaledCost, surcharge, baseCost, emeraldActive }); } }); @@ -2124,10 +2929,12 @@ async def root(): domainGrid.forEach((stack, idx) => { const top = topOfStack(stack); if (!top) return; - const evalRes = canAffordCost(p, { gold: Number(top.gold_cost || 0), strength: 0, magicMin: 0 }); + const baseCost = Number(top.gold_cost || 0); + const effectiveGold = Math.max(0, baseCost - (pratchettActive ? 1 : 0)); + const evalRes = canAffordCost(p, { gold: effectiveGold, strength: 0, magicMin: 0 }); console.log('[AFFORD_CHECK] domain', { stackIndex: idx, stackSize: stack?.length || 0, card: top, player: { G, S, M, V }, eval: evalRes }); if (top.is_visible && top.is_accessible && evalRes.ok) { - affordDomains.push({ card: top, stackIndex: idx, stackSize: stack.length, pay: evalRes }); + affordDomains.push({ card: top, stackIndex: idx, stackSize: stack.length, pay: evalRes, baseCost, effectiveGold, pratchettActive }); } }); @@ -2150,6 +2957,12 @@ async def root(): const resourcesLine = `
    Resources: G ${G} · S ${S} · M ${M} · VP ${V}
    `; + const activeEffects = []; + if (emeraldActive) activeEffects.push('Emerald Stronghold: ignore citizen duplicate surcharge'); + if (pratchettActive) activeEffects.push("Pratchett's Plateau: domains cost 1 less gold"); + const effectsBanner = activeEffects.length + ? `
    Active effects: ${escapeHtml(activeEffects.join(' · '))}
    ` + : ''; const takeResourceRow = isYou ? `
    @@ -2181,13 +2994,20 @@ async def root(): const dupHint = Number(it.surcharge || 0) ? ' (base ' + Number(it.baseCost || 0) + ' + ' + Number(it.surcharge || 0) + ' dupes)' : ''; - const costSummary = 'Cost: G ' + cost + ' · pay G' + pay.payGold + (pay.payMagic ? ', M' + pay.payMagic : '') + dupHint + ' · Stack ' + it.stackSize; + const emeraldHint = (!Number(it.surcharge || 0) && it.emeraldActive) + ? ' (Emerald: no duplicate surcharge)' + : ''; + const rulesText = cardFullText(c); + const rulesLine = rulesText + ? '
    ' + escapeHtml(rulesText) + '
    ' + : ''; + const costSummary = 'Cost: G ' + cost + ' · pay G' + pay.payGold + (pay.payMagic ? ', M' + pay.payMagic : '') + dupHint + emeraldHint + ' · Stack ' + it.stackSize; const btn = isYou ? '' : ''; return '
    ' + btn + - ' ' + escapeHtml(c.name) + ' (#' + c.citizen_id + ')' + roleHint + '' + + ' ' + escapeHtml(c.name) + ' (#' + c.citizen_id + ')' + roleHint + rulesLine + '' + ' ' + - '' + costSummary + '' + + '' + costSummary + '' + '
    ' + '' + '' + @@ -2199,15 +3019,22 @@ async def root(): const domainHtml = listSection('Domains (visible tops)', affordDomains, (it) => { const d = it.card; const key = 'd-' + d.domain_id; - const cost = Number(d.gold_cost || 0); + const cost = Number(it.effectiveGold ?? d.gold_cost ?? 0); const pay = it.pay; - const costSummary = 'Cost: G ' + cost + ' · pay G' + pay.payGold + (pay.payMagic ? ', M' + pay.payMagic : '') + ' · Stack ' + it.stackSize; - const btn = isYou ? '' : ''; + const pratchettHint = (it.pratchettActive && Number(it.baseCost || 0) !== cost) + ? ' (base ' + Number(it.baseCost || 0) + ' - 1 Pratchett)' + : ''; + const rulesText = cardFullText(d); + const rulesLine = rulesText + ? '
    ' + escapeHtml(rulesText) + '
    ' + : ''; + const costSummary = 'Cost: G ' + cost + ' · pay G' + pay.payGold + (pay.payMagic ? ', M' + pay.payMagic : '') + pratchettHint + ' · Stack ' + it.stackSize; + const btn = isYou ? '' : ''; return '
    ' + btn + - ' ' + escapeHtml(d.name) + ' (#' + d.domain_id + ')' + + ' ' + escapeHtml(d.name) + ' (#' + d.domain_id + ')' + rulesLine + '' + ' ' + - '' + costSummary + '' + + '' + costSummary + '' + '
    ' + '' + '' + @@ -2222,13 +3049,17 @@ async def root(): const sCost = Number(mcard.strength_cost || 0); const mMin = Number(mcard.magic_cost || 0); const pay = it.pay; + const rulesText = cardFullText(mcard); + const rulesLine = rulesText + ? '
    ' + escapeHtml(rulesText) + '
    ' + : ''; const costSummary = 'Cost: S ' + sCost + ' + M ' + mMin + ' min · pay S' + pay.payStrength + ', M' + pay.payMagic + ' · Stack ' + it.stackSize; const btn = isYou ? '' : ''; return '
    ' + btn + - ' ' + escapeHtml(mcard.name) + ' (#' + mcard.monster_id + ')' + + ' ' + escapeHtml(mcard.name) + ' (#' + mcard.monster_id + ')' + rulesLine + '' + ' ' + - '' + costSummary + '' + + '' + costSummary + '' + '
    ' + '' + '' + @@ -2241,13 +3072,14 @@ async def root():
    ${header} ${resourcesLine} + ${effectsBanner} ${takeResourceRow} ${citizenHtml} ${domainHtml} ${monsterHtml}
    `; - bindPayCostToggles(panel); + restorePayEditorRenderState(panel, payEditorState); } async function takeResourceFromChoice(resource) { @@ -2285,8 +3117,9 @@ async def root(): } } - async function sendHarvestCard(slotKey) { + async function sendHarvestCard(slotKey, options = {}) { if (!playerId || !currentGameId) return; + const suppressAlert = !!(options && options.suppressAlert); const sk = (slotKey || '').toString().trim(); if (!sk) return; try { @@ -2301,13 +3134,17 @@ async def root(): }); if (!res.ok) { const err = await res.json().catch(() => ({})); - alert(err.detail || res.statusText || 'Harvest failed'); + if (!suppressAlert) { + alert(err.detail || res.statusText || 'Harvest failed'); + } return; } getGameState(false); } catch (e) { console.error(e); - alert(e.message || 'Harvest failed'); + if (!suppressAlert) { + alert(e.message || 'Harvest failed'); + } } } @@ -2330,14 +3167,14 @@ async def root(): getGameState(false); } - async function buyDomain(domainId, goldCost, magicCost) { + async function buildDomain(domainId, goldCost, magicCost) { if (!playerId || !currentGameId) return; await fetch(`/api/game/${currentGameId}/action`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ player_id: playerId, - action_type: 'buy_domain', + action_type: 'build_domain', domain_id: domainId, payment: { gold: Number(goldCost || 0), @@ -2379,4 +3216,3 @@ async def root(): if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) - diff --git a/sql/domains_202304121016.sql b/sql/domains_202304121016.sql index 451ded6..a8ce98d 100644 --- a/sql/domains_202304121016.sql +++ b/sql/domains_202304121016.sql @@ -1,17 +1,17 @@ INSERT INTO vckonline.domains (name,gold_cost,shadow_count,holy_count,soldier_count,worker_count,vp_reward,has_activation_effect,has_passive_effect,passive_effect,activation_effect,`text`,image) VALUES - ('Jousting Field',13,1,0,1,1,3,0,1,NULL,NULL,'During your Harvest Phase, gain 1gp * Knight you own.',NULL), - ('Ancient Tomb',7,0,1,1,1,3,1,0,NULL,NULL,'Immediately add 3sp to a Monster Strength value.',NULL), - ('Foxgrove Palisade',9,1,0,1,0,3,0,1,NULL,NULL,'During your Roll Phase, you may pay 2gp to change one die to equal 6.',NULL), - ('The Desert Orchid',9,1,1,0,0,3,0,1,NULL,NULL,'During your Roll Phase, you may pay 1gp * owned Holy Citizens to change one die to equal 1.',NULL), - ('Pretorius Conclave',8,1,1,1,1,2,1,0,NULL,NULL,'Immediately gain a Citizen from the center stacks.',NULL), - ('Emerald Stronghold',12,0,1,1,1,5,0,1,NULL,NULL,'During your Action phase, ignore ''+'' when buying Citizens.',NULL), - ('Pratchett''s Plateau',8,0,1,0,1,3,0,1,NULL,NULL,'During your Action phase, Domains cost you 1gp less to buy.',NULL), - ('Shelly Commons',13,0,1,1,1,4,0,1,NULL,NULL,'At the end of your Action phase, pay 1gp to a Player of your choice to gain 1vp.',NULL), - ('Cathedral of St Aquila',8,0,2,0,0,3,0,1,NULL,NULL,'At the end of your Action phase, take 1gp from a Player of your choice.',NULL), - ('Cursed Cavern',10,1,1,0,1,2,1,0,NULL,NULL,'All players immediately flip a Citizen and you gain 4mp.',NULL); + ('Jousting Field',13,1,0,1,1,3,0,1,'harvest:gain_per_owned_citizen_name Knight g 1',NULL,'During your Harvest Phase, gain 1gp * Knight you own.',NULL), + ('Ancient Tomb',7,0,1,1,1,3,1,0,NULL,'activation_monster_strength +3','Immediately add 3sp to a Monster Strength value.',NULL), + ('Foxgrove Palisade',9,1,0,1,0,3,0,1,'roll:set_one_die target=6 cost=g:2',NULL,'During your Roll Phase, you may pay 2gp to change one die to equal 6.',NULL), + ('The Desert Orchid',9,1,1,0,0,3,0,1,'roll:set_one_die target=1 cost=g_per_owned_role:holy_citizen',NULL,'During your Roll Phase, you may pay 1gp * owned Holy Citizens to change one die to equal 1.',NULL), + ('Pretorius Conclave',8,1,1,1,1,2,1,0,NULL,'choose ','Immediately gain a Citizen from the center stacks.',NULL), + ('Emerald Stronghold',12,0,1,1,1,5,0,1,'effect:add action:emeraldstronghold',NULL,'During your Action phase, ignore ''+'' when buying Citizens.',NULL), + ('Pratchett''s Plateau',8,0,1,0,1,3,0,1,'effect:add action:pratchettsplateau',NULL,'During your Action phase, Domains cost you 1gp less to build.',NULL), + ('Shelley Commons',13,0,1,1,1,4,0,1,'action:end manipulate_resources mode=pay_to_player gain=vp:1 pay=g:1 optional=true',NULL,'At the end of your Action phase, pay 1gp to a Player of your choice to gain 1vp.',NULL), + ('Cathedral of St Aquila',8,0,2,0,0,3,0,1,'action:end manipulate_resources mode=take_from_player take=g:1 optional=true',NULL,'At the end of your Action phase, take 1gp from a Player of your choice.',NULL), + ('Cursed Cavern',10,1,1,0,1,2,1,0,NULL,'m 4 + concurrent_flip_one_citizen','All players immediately flip a Citizen and you gain 4mp.',NULL); INSERT INTO vckonline.domains (name,gold_cost,shadow_count,holy_count,soldier_count,worker_count,vp_reward,has_activation_effect,has_passive_effect,passive_effect,activation_effect,`text`,image) VALUES - ('Darktide Harbour',6,1,0,1,1,2,1,0,NULL,NULL,'Immediately gain a Shadow Citizen from the center stacks.',NULL), - ('King Tower',12,0,1,0,2,3,0,1,NULL,NULL,'At the end of your Action Phase, pay 1mp to a Player of your choice to gain 1vp.',NULL), - ('Cloudrider''s Camp',8,0,1,1,0,2,1,0,NULL,NULL,'Immediately gain 3sp and a Soldier Citizen worth 2gp or less.',NULL), - ('The Orb of Urdr',6,1,1,0,0,1,0,1,NULL,NULL,'At the end of your Action Phase, take 1mp from a Player of your choice.',NULL), - ('Wisborg',6,1,0,0,2,3,1,0,NULL,NULL,'You may immediately pay 3gp to gain 3vp.',NULL); + ('Darktide Harbour',6,1,0,1,1,2,1,0,NULL,'choose ','Immediately gain a Shadow Citizen from the center stacks.',NULL), + ('King Tower',12,0,1,0,2,3,0,1,'action:end manipulate_resources mode=pay_to_player gain=vp:1 pay=m:1 optional=true',NULL,'At the end of your Action Phase, pay 1mp to a Player of your choice to gain 1vp.',NULL), + ('Cloudrider''s Camp',8,0,1,1,0,2,1,0,NULL,'s 3 + choose ','Immediately gain 3sp and a Soldier Citizen worth 2gp or less.',NULL), + ('The Orb of Urdr',6,1,1,0,0,1,0,1,'action:end manipulate_resources mode=take_from_player take=m:1 optional=true',NULL,'At the end of your Action Phase, take 1mp from a Player of your choice.',NULL), + ('Wisborg',6,1,0,0,2,3,1,0,NULL,'manipulate_resources mode=self_convert pay=g:3 gain=vp:3 optional=true','You may immediately pay 3gp to gain 3vp.',NULL);