diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..79e6bc4 Binary files /dev/null and b/.DS_Store differ diff --git a/cards.py b/cards.py index 3aea7f0..ebb1901 100644 --- a/cards.py +++ b/cards.py @@ -378,3 +378,22 @@ class Duke(Card): return cls(duke_id, name, gold_mult, strength_mult, magic_mult, shadow_mult, holy_mult, soldier_mult, worker_mult, monster_mult, citizen_mult, domain_mult, boss_mult, minion_mult, beast_mult, titan_mult, expansion) + + +class Exhausted(Card): + def __init__(self, exhausted_id): + super().__init__() + self.exhausted_id = exhausted_id + self.name = "Exhausted" + self.toggle_visibility(True) + + def to_dict(self): + return { + **super().to_dict(), + "exhausted_id": self.exhausted_id, + "name": self.name, + } + + @classmethod + def from_dict(cls, d): + return cls(d["exhausted_id"]) diff --git a/docs/effect-strings.md b/docs/effect-strings.md new file mode 100644 index 0000000..dce7603 --- /dev/null +++ b/docs/effect-strings.md @@ -0,0 +1,181 @@ +# Effect String Syntax + +This document covers how effect strings work across the three card tables (citizens, domains, monsters), where the syntax currently diverges, and the proposed unified grammar. + +--- + +## Current state by table + +### Citizens — `special_payout_on_turn` / `special_payout_off_turn` + +| Card | String | Meaning | +|---|---|---| +| Merchant | `choose g 2 m 2` | Pick one: +2g or +2m | +| Mercenary | `exchange s 1 g 2` | Pay 1s, gain 2g | +| Champion | `exchange g 1 s 4` | Pay 1g, gain 4s | +| Paladin | `exchange s 1 m 3` | Pay 1s, gain 3m | +| Butcher | `count owned_worker g 2` | Gain 2g per owned Worker citizen | + +### Domains — `activation_effect` + +| Card | String | Meaning | +|---|---|---| +| Ancient Tomb | `action.modify_monster_strength +3` | Prompt: add 3 to a monster's strength cost | +| Pretorius Conclave | `choose ` | Prompt: take any citizen from the board | +| Cursed Cavern | `m 4 + concurrent_flip_one_citizen` | Gain 4m; all players flip a citizen | +| Darktide Harbour | `choose ` | Prompt: take a shadow citizen | +| Cloudrider's Camp | `s 3 + choose ` | Gain 3s; prompt: take a soldier citizen worth ≤2g | +| Wisborg | `manipulate_resources mode=self_convert pay=g:3 gain=v:3 optional=true` | Optionally pay 3g to gain 3vp | + +### Domains — `passive_effect` + +| Card | String | Meaning | +|---|---|---| +| Jousting Field | `harvest.gain_per_owned_citizen_name Knight g 1` | Harvest phase: gain 1g per Knight owned | +| Foxgrove Palisade | `roll.set_one_die target=6 cost=g:2` | Roll phase: pay 2g to set a die to 6 | +| The Desert Orchid | `roll.set_one_die target=1 cost=g_per_owned_role:holy_citizen` | Roll phase: pay 1g per holy citizen to set a die to 1 | +| Emerald Stronghold | `effect.add action.emeraldstronghold` | Flag: ignore + when buying citizens | +| Pratchett's Plateau | `effect.add action.pratchettsplateau` | Flag: domains cost 1g less | +| Shelley Commons | `action.end manipulate_resources mode=pay_to_player gain=v:1 pay=g:1 optional=true` | End of action: optionally pay 1g to a player for 1vp | +| Cathedral of St Aquila | `action.end manipulate_resources mode=take_from_player take=g:1 optional=true` | End of action: optionally take 1g from a player | +| King Tower | `action.end manipulate_resources mode=pay_to_player gain=v:1 pay=m:1 optional=true` | End of action: optionally pay 1m to a player for 1vp | +| The Orb of Urdr | `action.end manipulate_resources mode=take_from_player take=m:1 optional=true` | End of action: optionally take 1m from a player | + +### Monsters — `special_reward` + +| Card | String | Meaning | +|---|---|---| +| Goblin Mage | `choose g 1 m 1` | Pick one: +1g or +1m | +| Goblin Bomber | `choose g 2 m 2 s 2` | Pick one: +2g, +2m, or +2s | +| Goblin King | `count area Hills g 1` | Gain 1g per Hills monster slain | +| Skeleton King | `count area Ruins g 2` | Gain 2g per Ruins monster slain | +| Bane Spider | `choose g 3 ` | Pick one: +3g or take a Knight citizen | +| Ettercap | `choose ` | Take a citizen worth ≤2g | +| Spider Queen | `choose ` | Pick one: 2g per Forest monster slain, or take a citizen and gain 1vp | +| Satyr Mage | `choose g 5 m 5 s 5` | Pick one: +5g, +5m, or +5s | +| Troll | `count area Valley m 2` | Gain 2m per Valley monster slain | +| Dire Bear | `choose g 2 m 2` | Pick one: +2g or +2m | +| Orc Warrior | `choose ` | Take a citizen worth ≤3g | +| Orc Batrider | `choose ` | Take any citizen | +| Orc Chieftain | `count area Mountain g 2` | Gain 2g per Mountain monster slain | + +--- + +## Where the syntax diverges + +### 1. Resource notation — two forms + +Citizens and monsters use positional shorthand; domain KV pairs use colon notation: + +``` +# positional (citizens, monsters) +choose g 2 m 2 +count owned_worker g 2 + +# colon inside KV values (domain passives) +action.end manipulate_resources mode=pay_to_player gain=v:1 pay=g:1 +manipulate_resources mode=self_convert pay=g:3 gain=v:3 +roll.set_one_die cost=g:2 +``` + +### 2. `count` — same structure, different second word + +The two count patterns are syntactically parallel but semantically distinct. No unification needed beyond being aware they share a parser. + +``` +count owned_worker g 2 # count by citizen role owned +count area Hills g 1 # count by monster area slain +``` + +### 3. `choose` — brackets sometimes, not always + +The bracket vs no-bracket distinction does carry real meaning and is worth keeping: + +``` +choose g 1 m 1 # pick one of these resource amounts +choose g 3 # pick a resource amount OR an entity +choose # pick an entity from a filtered set +``` + +### 4. `exchange` — only exists in citizens + +No equivalent pattern in domains or monsters. Could be expressed as a compound but `exchange` is readable: + +``` +exchange s 1 g 2 # pay 1s, receive 2g +``` + +### 5. `.` is doing three different jobs + +``` +harvest.gain_per_owned_citizen_name ... # dot = phase separator (phase.verb) +roll.set_one_die ... # dot = phase separator (phase.verb) +action.end manipulate_resources ... # dot = phase separator, then space, then verb +action.modify_monster_strength +3 # dot = namespace separator, not timing +effect.add action.emeraldstronghold # dot = verb separator, then dot = namespace +``` + +### 6. `manipulate_resources` wrapper verbosity + +The `mode=` value is doing the same work as a first-word verb. The wrapper adds noise: + +``` +# current +action.end manipulate_resources mode=pay_to_player gain=v:1 pay=g:1 optional=true + +# without the wrapper — same information +action.end pay_to_player g 1 v 1 optional +``` + +--- + +## Proposed unified grammar + +### Core rules + +1. **`.` means phase prefix only.** The left side is always a timing trigger (`harvest`, `roll`, `action.end`). Bare verbs have no dot. +2. **Resource amounts are always positional: `g N`.** Colon notation (`g:N`) only appears inside `=` assignments in KV strings where a space would be ambiguous. +3. **`choose` uses brackets for entity picks, bare words for resource picks.** Mixed is allowed: `choose g 3 `. +4. **Compound effects use ` + `.** Each leg is a self-contained effect: `m 4 + concurrent_flip_one_citizen`. + +### Proposed rewrites + +**Domain activation:** + +``` +# before → after +action.modify_monster_strength +3 → modify_monster_strength 3 +m 4 + concurrent_flip_one_citizen → no change +manipulate_resources mode=self_convert pay=g:3 gain=v:3 ... → self_convert g 3 v 3 optional +choose → no change +s 3 + choose → no change +``` + +**Domain passive:** + +``` +# before → after +action.end manipulate_resources mode=pay_to_player gain=v:1 pay=g:1 optional=true → action.end pay_to_player g 1 v 1 optional +action.end manipulate_resources mode=take_from_player take=g:1 optional=true → action.end take_from_player g 1 optional +action.end manipulate_resources mode=pay_to_player gain=v:1 pay=m:1 optional=true → action.end pay_to_player m 1 v 1 optional +action.end manipulate_resources mode=take_from_player take=m:1 optional=true → action.end take_from_player m 1 optional + +harvest.gain_per_owned_citizen_name Knight g 1 → no change +roll.set_one_die target=6 cost=g:2 → no change +effect.add action.emeraldstronghold → no change +``` + +**Citizens and monsters:** no changes needed — syntax is already consistent within each table and the proposed rules codify what they already do. + +--- + +## What stays as parsed strings vs opaque keys + +All effects in the tables above are **parsed strings** — a new card with different numbers works without any code change. + +The only candidates for opaque keys are effects with branching prompt logic unique to a single card that cannot be generalized with different parameters: + +- `concurrent_flip_one_citizen` (Cursed Cavern) — multi-player concurrent event +- `modify_monster_strength 3` (Ancient Tomb) — board-state mutation prompt + +Even these stay as strings under the unified grammar; they just dispatch to named functions rather than an inline parsing branch. The DB string is the key; the function is the implementation. diff --git a/game.py b/game.py index 56cd117..360b86d 100644 --- a/game.py +++ b/game.py @@ -93,6 +93,28 @@ def _validate_monster_slay_payment(player, strength_cost, magic_min, gp, sp, mp) raise ValueError("Insufficient resources.") +def _player_resource_balances(player): + if not player: + return None + return { + "g": int(getattr(player, "gold_score", 0)), + "s": int(getattr(player, "strength_score", 0)), + "m": int(getattr(player, "magic_score", 0)), + "v": int(getattr(player, "victory_score", 0)), + } + + +def _balances_allow_payout(balances, payout_vec): + """balances: dict g,s,m,v; payout_vec: [dg, ds, dm, dv].""" + if not balances: + return False + keys = ("g", "s", "m", "v") + for i, k in enumerate(keys): + if int(balances.get(k, 0)) + int(payout_vec[i]) < 0: + return False + return True + + # --------------------------------------------------------------------------- # Concurrent (non-ordered) action subsystem. # @@ -216,6 +238,9 @@ class Game: self.rolled_die_two = game_state.get('rolled_die_two', self.die_two) self.rolled_die_sum = game_state.get('rolled_die_sum', self.die_sum) self.exhausted_count = game_state['exhausted_count'] + self.exhausted_stack = list(game_state.get('exhausted_stack') or []) + self.end_game_triggered = game_state.get('end_game_triggered', False) + self.final_scores = game_state.get('final_scores', None) self.effects = game_state['effects'] self.action_required = game_state['action_required'] # Concurrent (non-ordered) prompt: all listed players must respond before progression. @@ -237,6 +262,8 @@ class Game: 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 []) + self.pending_required_choice = game_state.get("pending_required_choice") + self._silent_harvest_batch = False # 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 @@ -285,6 +312,9 @@ class Game: Advance the game by one deterministic tick. This is intentionally small-grained so the server can call it implicitly. """ + if self.phase == 'game_over': + return False + # Block on any active concurrent (non-ordered) prompt first. if self.is_blocked_on_concurrent_action(): return False @@ -292,8 +322,15 @@ 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: 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": + if ( + self.action_required.get("action") == "bonus_resource_choice" + or aa == "manual_harvest" + or aa == "harvest_optional_exchange" + 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": @@ -337,8 +374,17 @@ class Game: if self.pending_action_end_queue: return False finisher = self._player_label(self.current_player_id()) + if not self.end_game_triggered: + reason = self._check_end_game_condition() + if reason: + self.end_game_triggered = True + self._log_game_event(f"End-game condition met ({reason}); finishing this round.") self.turn_index = (self.turn_index + 1) % max(1, len(self.player_list)) self.turn_number = int(self.turn_number) + 1 + if self.end_game_triggered and self.player_list[self.turn_index].is_first: + self._log_game_event(f"{finisher} ended their turn.") + self._finalize_game() + return True self.phase = 'roll' self.actions_remaining = 0 self.action_required["id"] = self.game_id @@ -401,8 +447,17 @@ class Game: if aid and aid != self.game_id and aact and aact != "standard_action": return False finisher = self._player_label(self.current_player_id()) + if not self.end_game_triggered: + reason = self._check_end_game_condition() + if reason: + self.end_game_triggered = True + self._log_game_event(f"End-game condition met ({reason}); finishing this round.") self.turn_index = (self.turn_index + 1) % max(1, len(self.player_list)) self.turn_number = int(self.turn_number) + 1 + if self.end_game_triggered and self.player_list[self.turn_index].is_first: + self._log_game_event(f"{finisher} ended their turn.") + self._finalize_game() + return True self.phase = 'roll' self.actions_remaining = 0 # Leaving action phase: clear the standard action prompt. @@ -447,8 +502,13 @@ 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: 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": + if self.action_required.get("action") in ( + "bonus_resource_choice", + "manual_harvest", + "harvest_optional_exchange", + ) 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(): @@ -970,7 +1030,7 @@ class Game: if not aid or aid == self.game_id: return False aa = self.action_required.get("action") or "" - if aa in ("bonus_resource_choice", "manual_harvest"): + if aa in ("bonus_resource_choice", "manual_harvest", "harvest_optional_exchange"): return True if str(aa).startswith("choose ") or str(aa).startswith("choose_player") or str(aa).startswith("choose_monster"): return True @@ -1099,21 +1159,25 @@ class Game: 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) - if not player: - continue - on_turn = pid == self.current_player_id() - consumed = [] - while True: - slots = self._harvest_slots_sorted_for_simulation( - self._build_harvest_slots(player, consumed, on_turn)) - if not slots: - break - for slot in slots: - self._apply_harvest_activation(player, slot["_obj"], slot["kind"], on_turn) - consumed.append(slot["slot_key"]) + self._silent_harvest_batch = True + try: + order = self._harvest_player_id_order_starting_active() + for pid in order: + player = self._player_by_id(pid) + if not player: + continue + on_turn = pid == self.current_player_id() + consumed = [] + while True: + slots = self._harvest_slots_sorted_for_simulation( + self._build_harvest_slots(player, consumed, on_turn)) + if not slots: + break + for slot in slots: + self._apply_harvest_activation(player, slot["_obj"], slot["kind"], on_turn) + consumed.append(slot["slot_key"]) + finally: + self._silent_harvest_batch = False for player in self.player_list: print(f"Player {player.name}: {player.gold_score} G, {player.strength_score} S, {player.magic_score} M," f" {player.victory_score} VP, Monsters: {len(player.owned_monsters)}, " @@ -1128,12 +1192,37 @@ class Game: return self._harvest_run_automation_until_blocked() - def _execute_compound_payout(self, compound_command, player_id, auto_apply_single_choice=True): + def _want_harvest_optional_exchange_prompt(self, raw_command): + """ + During interactive harvest only: pure \"exchange pay gain\" specials pause for confirm/skip. + Batch harvest_phase() sets _silent_harvest_batch so exchanges auto-resolve when affordable. + """ + if getattr(self, "phase", None) != "harvest": + return False + if getattr(self, "_silent_harvest_batch", False): + return False + rc = (raw_command or "").strip() + if " + " in rc: + return False + parts = rc.split() + if len(parts) < 5: + return False + return parts[0].lower() == "exchange" + + def _execute_compound_payout( + self, + compound_command, + player_id, + auto_apply_single_choice=True, + balance_hint=None, + suppress_exchange_optional_prompt=False, + ): """ 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]. + balance_hint: optional dict g,s,m,v carried across segments so exchange affordability sees prior legs. """ parts = [p.strip() for p in (compound_command or "").split(" + ")] if not parts: @@ -1144,6 +1233,9 @@ class Game: return [-9999, 0, 0, 0] prior_action = (self.action_required or {}).get("action", "") prior_concurrent = getattr(self, "concurrent_action", None) + bal = dict(balance_hint) if balance_hint is not None else _player_resource_balances(player) + if not bal: + return [-9999, 0, 0, 0] for cmd in parts: if not cmd: continue @@ -1151,6 +1243,8 @@ class Game: cmd, player_id, auto_apply_single_choice=auto_apply_single_choice, + balance_hint=bal, + suppress_exchange_optional_prompt=suppress_exchange_optional_prompt, ) new_action = (self.action_required or {}).get("action", "") new_concurrent = getattr(self, "concurrent_action", None) @@ -1165,13 +1259,24 @@ class Game: if prior_empty: return payout return total_payout + bal["g"] = int(bal["g"]) + int(payout[0]) + bal["s"] = int(bal["s"]) + int(payout[1]) + bal["m"] = int(bal["m"]) + int(payout[2]) + bal["v"] = int(bal["v"]) + int(payout[3]) 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): + def execute_special_payout( + self, + command, + player_id, + auto_apply_single_choice=True, + balance_hint=None, + suppress_exchange_optional_prompt=False, + ): print("executing special payout") raw = (command or "").strip() low = raw.lower() @@ -1185,6 +1290,8 @@ class Game: raw, player_id, auto_apply_single_choice=auto_apply_single_choice, + balance_hint=balance_hint, + suppress_exchange_optional_prompt=suppress_exchange_optional_prompt, ) payout = [0, 0, 0, 0] # gp, sp, mp, vp, todo: citizen, monster, domain split_command = (command or "").split() @@ -1246,6 +1353,11 @@ class Game: case _: payout[0] = -9999 case "exchange": + player_x = self._player_by_id(player_id) + if not player_x: + payout[0] = -9999 + print(payout) + return payout match second_word: case 'g': payout[0] = payout[0] - int(third_word) @@ -1268,6 +1380,27 @@ class Game: payout[3] = payout[3] + int(split_command[4]) case _: payout[0] = -9999 + if payout[0] == -9999: + print(payout) + return payout + bal_x = balance_hint if balance_hint is not None else _player_resource_balances(player_x) + if not _balances_allow_payout(bal_x, payout): + return [0, 0, 0, 0] + if ( + not suppress_exchange_optional_prompt + and balance_hint is None + and self._want_harvest_optional_exchange_prompt(raw) + ): + self.pending_required_choice = { + "kind": "harvest_optional_exchange", + "player_id": player_id, + "command": raw, + } + self.action_required["id"] = player_id + self.action_required["action"] = "harvest_optional_exchange" + return [0, 0, 0, 0] + print(payout) + return payout case "choose": normalized, options = self._normalize_choose_command(command) options = self._filter_unavailable_choose_options(options) @@ -2282,6 +2415,47 @@ class Game: self.action_required['id'] = self.game_id return + if current_required == "harvest_optional_exchange": + prc_h = getattr(self, "pending_required_choice", None) or {} + if prc_h.get("kind") != "harvest_optional_exchange" or prc_h.get("player_id") != player_id: + return + act_h = (action or "").strip().lower() + if act_h not in ("confirm_harvest_exchange", "skip_harvest_exchange"): + return + cmd_h = (prc_h.get("command") or "").strip() + target_h = self._player_by_id(player_id) + self.pending_required_choice = None + self.action_required["action"] = "" + self.action_required["id"] = self.game_id + if not target_h or not cmd_h: + self._maybe_resume_harvest_prompt() + return + before_h = self._player_scores_line(target_h) + if act_h == "skip_harvest_exchange": + self._log_game_event( + f"{self._player_label(player_id)} skipped optional harvest exchange ({cmd_h}); " + f"scores unchanged ({before_h})." + ) + self._maybe_resume_harvest_prompt() + return + payout_h = self.execute_special_payout( + cmd_h, + player_id, + suppress_exchange_optional_prompt=True, + ) + if isinstance(payout_h, list) and len(payout_h) >= 4 and payout_h[0] != -9999: + target_h.gold_score = int(target_h.gold_score) + int(payout_h[0]) + target_h.strength_score = int(target_h.strength_score) + int(payout_h[1]) + target_h.magic_score = int(target_h.magic_score) + int(payout_h[2]) + target_h.victory_score = int(getattr(target_h, "victory_score", 0)) + int(payout_h[3]) + self._bump_harvest_delta(target_h, payout_h[0], payout_h[1], payout_h[2], payout_h[3]) + after_h = self._player_scores_line(target_h) + self._log_game_event( + f"{self._player_label(player_id)} took harvest exchange ({cmd_h}); scores {before_h} -> {after_h}" + ) + self._maybe_resume_harvest_prompt() + 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() @@ -2455,12 +2629,18 @@ class Game: self._log_game_event(f"All players finished: {ca.get('kind')}.") handler.finalize(self) self.concurrent_action = None - # If we were stalled in setup, drive the engine forward so the - # next state the client polls is something actionable. + # Drive the engine forward after the concurrent action resolves. if self.phase == "setup": + # Setup stall: advance until the first actionable state. while self.advance_tick(): if self.phase == "action": break + else: + # Mid-game concurrent action (e.g. Cursed Cavern flip during action phase): + # if the active player spent their last action before the concurrent prompt, + # finish_turn_if_no_actions_remaining will advance the turn now that the + # block is cleared. If they still have actions, this is a no-op. + self.finish_turn_if_no_actions_remaining() def update_payout_for_role(self, role_name, player_id, payout, split_command): role_count = 0 @@ -2560,6 +2740,10 @@ class Game: if citizen_stack: citizen_stack[-1].toggle_accessibility(True) + elif self.exhausted_stack: + exhausted = self.exhausted_stack.pop() + citizen_stack.append(exhausted) + self.exhausted_count = int(self.exhausted_count) + 1 after = self._player_scores_line(player) pay = self._format_resource_payment(gp, sp, mp) self._log_game_event( @@ -2688,6 +2872,10 @@ class Game: if domain_stack: domain_stack[-1].toggle_visibility(True) domain_stack[-1].toggle_accessibility(True) + elif self.exhausted_stack: + exhausted = self.exhausted_stack.pop() + domain_stack.append(exhausted) + self.exhausted_count = int(self.exhausted_count) + 1 self._apply_domain_activation_effect(player, bought) after = self._player_scores_line(player) pay = self._format_resource_payment(gp, sp, mp) @@ -2736,6 +2924,85 @@ class Game: self.harvest_phase() self.action_phase() + def _check_end_game_condition(self): + """Returns a reason string if any end condition is met, else None.""" + from cards import Exhausted + if all(not stack for stack in self.monster_grid): + return "all monsters slain" + if all(not stack for stack in self.domain_grid): + return "all domains built" + if int(self.exhausted_count) >= len(self.player_list) * 2: + return "exhausted stacks filled" + return None + + def _calculate_final_scores(self): + """Compute final VP for each player including Duke multipliers. Returns ranked list.""" + self.unflip_all_citizens_for_final_scoring() + scores = [] + for player in self.player_list: + duke_vp = 0 + if player.owned_dukes: + duke = player.owned_dukes[0] + roles = player.calc_roles() + monster_attrs = self.owned_monster_attributes(player.player_id) + + def _res(score, divisor): + d = int(divisor or 0) + return int(score) // d if d > 0 else 0 + + def _cnt(count, multiplier): + return int(count) * int(multiplier or 0) + + duke_vp = ( + _res(player.gold_score, duke.gold_multiplier) + + _res(player.strength_score, duke.strength_multiplier) + + _res(player.magic_score, duke.magic_multiplier) + + _cnt(roles["shadow_count"], duke.shadow_multiplier) + + _cnt(roles["holy_count"], duke.holy_multiplier) + + _cnt(roles["soldier_count"], duke.soldier_multiplier) + + _cnt(roles["worker_count"], duke.worker_multiplier) + + _cnt(len(player.owned_monsters), duke.monster_multiplier) + + _cnt(len(player.owned_citizens), duke.citizen_multiplier) + + _cnt(len(player.owned_domains), duke.domain_multiplier) + + _cnt(monster_attrs.get("Boss", 0), duke.boss_multiplier) + + _cnt(monster_attrs.get("Minion", 0), duke.minion_multiplier) + + _cnt(monster_attrs.get("Beast", 0), duke.beast_multiplier) + + _cnt(monster_attrs.get("Titan", 0), duke.titan_multiplier) + ) + total_vp = int(player.victory_score) + duke_vp + tableau_size = ( + len(player.owned_starters) + + len(player.owned_citizens) + + len(player.owned_domains) + + len(player.owned_monsters) + + len(player.owned_dukes) + ) + scores.append({ + "player_id": player.player_id, + "name": player.name, + "base_vp": int(player.victory_score), + "duke_vp": duke_vp, + "total_vp": total_vp, + "tableau_size": tableau_size, + }) + scores.sort(key=lambda s: (-s["total_vp"], s["tableau_size"])) + for rank, s in enumerate(scores): + s["rank"] = rank + 1 + return scores + + def _finalize_game(self): + """Compute final scores, set phase to game_over, and log the result.""" + self.final_scores = self._calculate_final_scores() + self.phase = "game_over" + if self.final_scores: + for s in self.final_scores: + place = {1: "1st", 2: "2nd", 3: "3rd"}.get(s["rank"], f"{s['rank']}th") + self._log_game_event( + f"{place}: {s['name']} — {s['total_vp']} VP " + f"({s['base_vp']} base + {s['duke_vp']} Duke)." + ) + self._log_game_event(f"Game over! {self.final_scores[0]['name']} wins!") + def end_check(self): if self.exhausted_count <= (len(self.player_list) * 2): return False diff --git a/game_serialization.py b/game_serialization.py index e105ed5..2f1408e 100644 --- a/game_serialization.py +++ b/game_serialization.py @@ -1,6 +1,6 @@ from json import JSONEncoder -from cards import Citizen, Domain, Duke, Monster, Starter +from cards import Citizen, Domain, Duke, Exhausted, Monster, Starter from game_models import GameMember, LobbyMember, Player @@ -72,6 +72,8 @@ class GameObjectEncoder(JSONEncoder): return obj.to_dict() if isinstance(obj, Domain): return obj.to_dict() + if isinstance(obj, Exhausted): + 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 @@ -91,6 +93,9 @@ class GameObjectEncoder(JSONEncoder): "rolled_die_sum": getattr(obj, "rolled_die_sum", obj.die_sum), "pending_roll": getattr(obj, "pending_roll", None), "exhausted_count": obj.exhausted_count, + "exhausted_stack_size": len(getattr(obj, "exhausted_stack", None) or []), + "end_game_triggered": getattr(obj, "end_game_triggered", False), + "final_scores": getattr(obj, "final_scores", None), "effects": obj.effects, "action_required": obj.action_required, "pending_required_choice": getattr(obj, "pending_required_choice", None), diff --git a/game_setup.py b/game_setup.py index 00840c7..1f72003 100644 --- a/game_setup.py +++ b/game_setup.py @@ -1,7 +1,7 @@ import random from typing import List -from cards import Citizen, Domain, Duke, Monster, Starter +from cards import Citizen, Domain, Duke, Exhausted, Monster, Starter from game_models import Player @@ -16,6 +16,7 @@ def load_game_data(game_id, preset, player_list_from_lobby, debug_starting_resou domain_stack = [] duke_query = "select_random_dukes" duke_stack = [] + exhausted_stack = [Exhausted(i) for i in range(len(player_list_from_lobby) * 2)] starter_query = "SELECT * FROM starters" starter_stack = [] player_list = [] @@ -256,6 +257,7 @@ def load_game_data(game_id, preset, player_list_from_lobby, debug_starting_resou game_state = { "game_id": game_id, + "pending_required_choice": None, "player_list": player_list, "monster_grid": monster_grid, "citizen_grid": citizen_grid, @@ -264,6 +266,9 @@ def load_game_data(game_id, preset, player_list_from_lobby, debug_starting_resou "die_two": die_two, "die_sum": die_sum, "exhausted_count": exhausted_count, + "exhausted_stack": exhausted_stack, + "end_game_triggered": False, + "final_scores": None, "effects": effects, "action_required": action_required, "concurrent_action": None, diff --git a/images/.DS_Store b/images/.DS_Store new file mode 100644 index 0000000..8f0c4ba Binary files /dev/null and b/images/.DS_Store differ diff --git a/images/citizen_01_cleric.jpg b/images/citizens/citizen_01_cleric.jpg similarity index 100% rename from images/citizen_01_cleric.jpg rename to images/citizens/citizen_01_cleric.jpg diff --git a/images/citizen_02_merchant.jpg b/images/citizens/citizen_02_merchant.jpg similarity index 100% rename from images/citizen_02_merchant.jpg rename to images/citizens/citizen_02_merchant.jpg diff --git a/images/citizen_03_mercenary.jpg b/images/citizens/citizen_03_mercenary.jpg similarity index 100% rename from images/citizen_03_mercenary.jpg rename to images/citizens/citizen_03_mercenary.jpg diff --git a/images/citizen_04_archer.jpg b/images/citizens/citizen_04_archer.jpg similarity index 100% rename from images/citizen_04_archer.jpg rename to images/citizens/citizen_04_archer.jpg diff --git a/images/citizen_05_peasant.jpg b/images/citizens/citizen_05_peasant.jpg similarity index 100% rename from images/citizen_05_peasant.jpg rename to images/citizens/citizen_05_peasant.jpg diff --git a/images/citizen_06_knight.jpg b/images/citizens/citizen_06_knight.jpg similarity index 100% rename from images/citizen_06_knight.jpg rename to images/citizens/citizen_06_knight.jpg diff --git a/images/citizen_07_rogue.jpg b/images/citizens/citizen_07_rogue.jpg similarity index 100% rename from images/citizen_07_rogue.jpg rename to images/citizens/citizen_07_rogue.jpg diff --git a/images/citizen_08_champion.jpg b/images/citizens/citizen_08_champion.jpg similarity index 100% rename from images/citizen_08_champion.jpg rename to images/citizens/citizen_08_champion.jpg diff --git a/images/citizen_09_paladin.jpg b/images/citizens/citizen_09_paladin.jpg similarity index 100% rename from images/citizen_09_paladin.jpg rename to images/citizens/citizen_09_paladin.jpg diff --git a/images/citizen_10_butcher.jpg b/images/citizens/citizen_10_butcher.jpg similarity index 100% rename from images/citizen_10_butcher.jpg rename to images/citizens/citizen_10_butcher.jpg diff --git a/images/citizen_back.jpg b/images/citizens/citizen_back.jpg similarity index 100% rename from images/citizen_back.jpg rename to images/citizens/citizen_back.jpg diff --git a/images/domains/domain_01_jousting_field.jpg b/images/domains/domain_01_jousting_field.jpg new file mode 100644 index 0000000..9e2ba80 Binary files /dev/null and b/images/domains/domain_01_jousting_field.jpg differ diff --git a/images/domains/domain_02_ancient_tomb.jpg b/images/domains/domain_02_ancient_tomb.jpg new file mode 100644 index 0000000..467e5d8 Binary files /dev/null and b/images/domains/domain_02_ancient_tomb.jpg differ diff --git a/images/domains/domain_03_foxgrove_palisade.jpg b/images/domains/domain_03_foxgrove_palisade.jpg new file mode 100644 index 0000000..15fb543 Binary files /dev/null and b/images/domains/domain_03_foxgrove_palisade.jpg differ diff --git a/images/domains/domain_04_the_desert_orchid.jpg b/images/domains/domain_04_the_desert_orchid.jpg new file mode 100644 index 0000000..557c776 Binary files /dev/null and b/images/domains/domain_04_the_desert_orchid.jpg differ diff --git a/images/domains/domain_05_pretorius_conclave.jpg b/images/domains/domain_05_pretorius_conclave.jpg new file mode 100644 index 0000000..9b98b88 Binary files /dev/null and b/images/domains/domain_05_pretorius_conclave.jpg differ diff --git a/images/domains/domain_06_emerald_stronghold.jpg b/images/domains/domain_06_emerald_stronghold.jpg new file mode 100644 index 0000000..702c827 Binary files /dev/null and b/images/domains/domain_06_emerald_stronghold.jpg differ diff --git a/images/domains/domain_07_pratchetts_plateau.jpg b/images/domains/domain_07_pratchetts_plateau.jpg new file mode 100644 index 0000000..67c6705 Binary files /dev/null and b/images/domains/domain_07_pratchetts_plateau.jpg differ diff --git a/images/domains/domain_08_shelley_commons.jpg b/images/domains/domain_08_shelley_commons.jpg new file mode 100644 index 0000000..6ac03c7 Binary files /dev/null and b/images/domains/domain_08_shelley_commons.jpg differ diff --git a/images/domains/domain_09_cathedral_of_st_aquila.jpg b/images/domains/domain_09_cathedral_of_st_aquila.jpg new file mode 100644 index 0000000..a2a1aae Binary files /dev/null and b/images/domains/domain_09_cathedral_of_st_aquila.jpg differ diff --git a/images/domains/domain_10_cursed_cavern.jpg b/images/domains/domain_10_cursed_cavern.jpg new file mode 100644 index 0000000..d07346e Binary files /dev/null and b/images/domains/domain_10_cursed_cavern.jpg differ diff --git a/images/domains/domain_11_darktide_harbor.jpg b/images/domains/domain_11_darktide_harbor.jpg new file mode 100644 index 0000000..19f1f02 Binary files /dev/null and b/images/domains/domain_11_darktide_harbor.jpg differ diff --git a/images/domains/domain_12_king_tower.jpg b/images/domains/domain_12_king_tower.jpg new file mode 100644 index 0000000..ff3016d Binary files /dev/null and b/images/domains/domain_12_king_tower.jpg differ diff --git a/images/domains/domain_13_cloudriders_camp.jpg b/images/domains/domain_13_cloudriders_camp.jpg new file mode 100644 index 0000000..4721a5c Binary files /dev/null and b/images/domains/domain_13_cloudriders_camp.jpg differ diff --git a/images/domains/domain_14_the_orb_of_urdr.jpg b/images/domains/domain_14_the_orb_of_urdr.jpg new file mode 100644 index 0000000..ef7ca66 Binary files /dev/null and b/images/domains/domain_14_the_orb_of_urdr.jpg differ diff --git a/images/domains/domain_15_wisborg.jpg b/images/domains/domain_15_wisborg.jpg new file mode 100644 index 0000000..b0a4eb2 Binary files /dev/null and b/images/domains/domain_15_wisborg.jpg differ diff --git a/images/domain_back.jpg b/images/domains/domain_back.jpg similarity index 100% rename from images/domain_back.jpg rename to images/domains/domain_back.jpg diff --git a/images/domains/domain_r01c01.jpg b/images/domains/domain_r01c01.jpg new file mode 100644 index 0000000..60a5340 Binary files /dev/null and b/images/domains/domain_r01c01.jpg differ diff --git a/images/domains/domain_r01c03.jpg b/images/domains/domain_r01c03.jpg new file mode 100644 index 0000000..7b698ba Binary files /dev/null and b/images/domains/domain_r01c03.jpg differ diff --git a/images/domains/domain_r01c04.jpg b/images/domains/domain_r01c04.jpg new file mode 100644 index 0000000..1c4fb21 Binary files /dev/null and b/images/domains/domain_r01c04.jpg differ diff --git a/images/domains/domain_r01c05.jpg b/images/domains/domain_r01c05.jpg new file mode 100644 index 0000000..ede2312 Binary files /dev/null and b/images/domains/domain_r01c05.jpg differ diff --git a/images/domains/domain_r01c07.jpg b/images/domains/domain_r01c07.jpg new file mode 100644 index 0000000..3692774 Binary files /dev/null and b/images/domains/domain_r01c07.jpg differ diff --git a/images/domains/domain_r01c09.jpg b/images/domains/domain_r01c09.jpg new file mode 100644 index 0000000..5b52672 Binary files /dev/null and b/images/domains/domain_r01c09.jpg differ diff --git a/images/domains/domain_r01c10.jpg b/images/domains/domain_r01c10.jpg new file mode 100644 index 0000000..23d3f08 Binary files /dev/null and b/images/domains/domain_r01c10.jpg differ diff --git a/images/domains/domain_r02c01.jpg b/images/domains/domain_r02c01.jpg new file mode 100644 index 0000000..f94ebf3 Binary files /dev/null and b/images/domains/domain_r02c01.jpg differ diff --git a/images/domains/domain_r02c02.jpg b/images/domains/domain_r02c02.jpg new file mode 100644 index 0000000..0f9d6cf Binary files /dev/null and b/images/domains/domain_r02c02.jpg differ diff --git a/images/domains/domain_r02c03.jpg b/images/domains/domain_r02c03.jpg new file mode 100644 index 0000000..53c25e4 Binary files /dev/null and b/images/domains/domain_r02c03.jpg differ diff --git a/images/domains/domain_r02c05.jpg b/images/domains/domain_r02c05.jpg new file mode 100644 index 0000000..21482db Binary files /dev/null and b/images/domains/domain_r02c05.jpg differ diff --git a/images/domains/domain_r02c06.jpg b/images/domains/domain_r02c06.jpg new file mode 100644 index 0000000..ff3f6e9 Binary files /dev/null and b/images/domains/domain_r02c06.jpg differ diff --git a/images/domains/domain_r03c01.jpg b/images/domains/domain_r03c01.jpg new file mode 100644 index 0000000..0df96bc Binary files /dev/null and b/images/domains/domain_r03c01.jpg differ diff --git a/images/domains/domain_r03c02.jpg b/images/domains/domain_r03c02.jpg new file mode 100644 index 0000000..1858126 Binary files /dev/null and b/images/domains/domain_r03c02.jpg differ diff --git a/images/domains/domain_r03c03.jpg b/images/domains/domain_r03c03.jpg new file mode 100644 index 0000000..ee10c6b Binary files /dev/null and b/images/domains/domain_r03c03.jpg differ diff --git a/images/domains/domain_r03c04.jpg b/images/domains/domain_r03c04.jpg new file mode 100644 index 0000000..8c54ef6 Binary files /dev/null and b/images/domains/domain_r03c04.jpg differ diff --git a/images/domains/domain_r03c05.jpg b/images/domains/domain_r03c05.jpg new file mode 100644 index 0000000..265806c Binary files /dev/null and b/images/domains/domain_r03c05.jpg differ diff --git a/images/domains/domain_r03c06.jpg b/images/domains/domain_r03c06.jpg new file mode 100644 index 0000000..c84a6cf Binary files /dev/null and b/images/domains/domain_r03c06.jpg differ diff --git a/images/domains/domain_r03c07.jpg b/images/domains/domain_r03c07.jpg new file mode 100644 index 0000000..32e4bea Binary files /dev/null and b/images/domains/domain_r03c07.jpg differ diff --git a/images/domains/domain_r03c09.jpg b/images/domains/domain_r03c09.jpg new file mode 100644 index 0000000..9a87374 Binary files /dev/null and b/images/domains/domain_r03c09.jpg differ diff --git a/images/domains/domain_r03c10.jpg b/images/domains/domain_r03c10.jpg new file mode 100644 index 0000000..c5541fa Binary files /dev/null and b/images/domains/domain_r03c10.jpg differ diff --git a/images/domains/domain_r04c01.jpg b/images/domains/domain_r04c01.jpg new file mode 100644 index 0000000..0da08a9 Binary files /dev/null and b/images/domains/domain_r04c01.jpg differ diff --git a/images/domains/domain_r04c02.jpg b/images/domains/domain_r04c02.jpg new file mode 100644 index 0000000..e69db11 Binary files /dev/null and b/images/domains/domain_r04c02.jpg differ diff --git a/images/domains/domain_r04c03.jpg b/images/domains/domain_r04c03.jpg new file mode 100644 index 0000000..217f946 Binary files /dev/null and b/images/domains/domain_r04c03.jpg differ diff --git a/images/domains/domain_r04c04.jpg b/images/domains/domain_r04c04.jpg new file mode 100644 index 0000000..476002c Binary files /dev/null and b/images/domains/domain_r04c04.jpg differ diff --git a/images/domains/domain_r04c05.jpg b/images/domains/domain_r04c05.jpg new file mode 100644 index 0000000..001b7ef Binary files /dev/null and b/images/domains/domain_r04c05.jpg differ diff --git a/images/domains/domain_r04c06.jpg b/images/domains/domain_r04c06.jpg new file mode 100644 index 0000000..5e49acf Binary files /dev/null and b/images/domains/domain_r04c06.jpg differ diff --git a/images/domains/domain_r04c07.jpg b/images/domains/domain_r04c07.jpg new file mode 100644 index 0000000..61877a0 Binary files /dev/null and b/images/domains/domain_r04c07.jpg differ diff --git a/images/domains/domain_r04c08.jpg b/images/domains/domain_r04c08.jpg new file mode 100644 index 0000000..3be4279 Binary files /dev/null and b/images/domains/domain_r04c08.jpg differ diff --git a/images/domains/domain_r04c09.jpg b/images/domains/domain_r04c09.jpg new file mode 100644 index 0000000..ea986d8 Binary files /dev/null and b/images/domains/domain_r04c09.jpg differ diff --git a/images/domains/domain_r04c10.jpg b/images/domains/domain_r04c10.jpg new file mode 100644 index 0000000..20d8525 Binary files /dev/null and b/images/domains/domain_r04c10.jpg differ diff --git a/images/domains/domain_r05c01.jpg b/images/domains/domain_r05c01.jpg new file mode 100644 index 0000000..c0a0724 Binary files /dev/null and b/images/domains/domain_r05c01.jpg differ diff --git a/images/domains/domain_r05c02.jpg b/images/domains/domain_r05c02.jpg new file mode 100644 index 0000000..59c4959 Binary files /dev/null and b/images/domains/domain_r05c02.jpg differ diff --git a/images/domains/domain_r05c03.jpg b/images/domains/domain_r05c03.jpg new file mode 100644 index 0000000..33af8ad Binary files /dev/null and b/images/domains/domain_r05c03.jpg differ diff --git a/images/domains/domain_r05c04.jpg b/images/domains/domain_r05c04.jpg new file mode 100644 index 0000000..0dbcd89 Binary files /dev/null and b/images/domains/domain_r05c04.jpg differ diff --git a/images/domains/domain_r05c05.jpg b/images/domains/domain_r05c05.jpg new file mode 100644 index 0000000..f08c007 Binary files /dev/null and b/images/domains/domain_r05c05.jpg differ diff --git a/images/domains/domain_r05c06.jpg b/images/domains/domain_r05c06.jpg new file mode 100644 index 0000000..95ce4e6 Binary files /dev/null and b/images/domains/domain_r05c06.jpg differ diff --git a/images/domains/domain_r05c07.jpg b/images/domains/domain_r05c07.jpg new file mode 100644 index 0000000..4dc492b Binary files /dev/null and b/images/domains/domain_r05c07.jpg differ diff --git a/images/domains/domain_r05c08.jpg b/images/domains/domain_r05c08.jpg new file mode 100644 index 0000000..9489e39 Binary files /dev/null and b/images/domains/domain_r05c08.jpg differ diff --git a/images/domains/domain_r05c10.jpg b/images/domains/domain_r05c10.jpg new file mode 100644 index 0000000..266af3f Binary files /dev/null and b/images/domains/domain_r05c10.jpg differ diff --git a/images/domains/domain_r06c01.jpg b/images/domains/domain_r06c01.jpg new file mode 100644 index 0000000..b7e49ab Binary files /dev/null and b/images/domains/domain_r06c01.jpg differ diff --git a/images/domains/domain_r06c02.jpg b/images/domains/domain_r06c02.jpg new file mode 100644 index 0000000..f8f6440 Binary files /dev/null and b/images/domains/domain_r06c02.jpg differ diff --git a/images/domains/domain_r06c03.jpg b/images/domains/domain_r06c03.jpg new file mode 100644 index 0000000..6fb7813 Binary files /dev/null and b/images/domains/domain_r06c03.jpg differ diff --git a/images/domains/domain_r06c05.jpg b/images/domains/domain_r06c05.jpg new file mode 100644 index 0000000..c30e2b0 Binary files /dev/null and b/images/domains/domain_r06c05.jpg differ diff --git a/images/domains/domain_r06c06.jpg b/images/domains/domain_r06c06.jpg new file mode 100644 index 0000000..d5da63d Binary files /dev/null and b/images/domains/domain_r06c06.jpg differ diff --git a/images/domains/domain_r06c07.jpg b/images/domains/domain_r06c07.jpg new file mode 100644 index 0000000..96a0280 Binary files /dev/null and b/images/domains/domain_r06c07.jpg differ diff --git a/images/domains/domain_r06c09.jpg b/images/domains/domain_r06c09.jpg new file mode 100644 index 0000000..e3fe35a Binary files /dev/null and b/images/domains/domain_r06c09.jpg differ diff --git a/images/domains/domain_r06c10.jpg b/images/domains/domain_r06c10.jpg new file mode 100644 index 0000000..b38ed0d Binary files /dev/null and b/images/domains/domain_r06c10.jpg differ diff --git a/images/domains/domain_r07c01.jpg b/images/domains/domain_r07c01.jpg new file mode 100644 index 0000000..68bfae6 Binary files /dev/null and b/images/domains/domain_r07c01.jpg differ diff --git a/images/domains/domain_r07c02.jpg b/images/domains/domain_r07c02.jpg new file mode 100644 index 0000000..7d181e7 Binary files /dev/null and b/images/domains/domain_r07c02.jpg differ diff --git a/images/domains/domain_r07c03.jpg b/images/domains/domain_r07c03.jpg new file mode 100644 index 0000000..57fabb8 Binary files /dev/null and b/images/domains/domain_r07c03.jpg differ diff --git a/images/domains/domain_r07c04.jpg b/images/domains/domain_r07c04.jpg new file mode 100644 index 0000000..80269d5 Binary files /dev/null and b/images/domains/domain_r07c04.jpg differ diff --git a/images/domains/domain_r07c06.jpg b/images/domains/domain_r07c06.jpg new file mode 100644 index 0000000..8f32437 Binary files /dev/null and b/images/domains/domain_r07c06.jpg differ diff --git a/images/domains/domain_r07c08.jpg b/images/domains/domain_r07c08.jpg new file mode 100644 index 0000000..95d4aff Binary files /dev/null and b/images/domains/domain_r07c08.jpg differ diff --git a/images/domains/domain_r07c09.jpg b/images/domains/domain_r07c09.jpg new file mode 100644 index 0000000..fc9bdf9 Binary files /dev/null and b/images/domains/domain_r07c09.jpg differ diff --git a/images/dukes/duke_01_elisium_the_allsmith.jpg b/images/dukes/duke_01_elisium_the_allsmith.jpg new file mode 100644 index 0000000..f7af493 Binary files /dev/null and b/images/dukes/duke_01_elisium_the_allsmith.jpg differ diff --git a/images/dukes/duke_02_reese_the_firebrand.jpg b/images/dukes/duke_02_reese_the_firebrand.jpg new file mode 100644 index 0000000..f645130 Binary files /dev/null and b/images/dukes/duke_02_reese_the_firebrand.jpg differ diff --git a/images/dukes/duke_03_tsoukalos_the_conspirator.jpg b/images/dukes/duke_03_tsoukalos_the_conspirator.jpg new file mode 100644 index 0000000..eb708d6 Binary files /dev/null and b/images/dukes/duke_03_tsoukalos_the_conspirator.jpg differ diff --git a/images/dukes/duke_04_cornelius_the_dreamer.jpg b/images/dukes/duke_04_cornelius_the_dreamer.jpg new file mode 100644 index 0000000..c69b658 Binary files /dev/null and b/images/dukes/duke_04_cornelius_the_dreamer.jpg differ diff --git a/images/dukes/duke_05_mico_the_monster_slayer.jpg b/images/dukes/duke_05_mico_the_monster_slayer.jpg new file mode 100644 index 0000000..a8459e5 Binary files /dev/null and b/images/dukes/duke_05_mico_the_monster_slayer.jpg differ diff --git a/images/dukes/duke_06_waybright_the_wise.jpg b/images/dukes/duke_06_waybright_the_wise.jpg new file mode 100644 index 0000000..b858daf Binary files /dev/null and b/images/dukes/duke_06_waybright_the_wise.jpg differ diff --git a/images/dukes/duke_07_mulholland_the_brave.jpg b/images/dukes/duke_07_mulholland_the_brave.jpg new file mode 100644 index 0000000..c8421ed Binary files /dev/null and b/images/dukes/duke_07_mulholland_the_brave.jpg differ diff --git a/images/dukes/duke_08_hrothgar_the_conqueror.jpg b/images/dukes/duke_08_hrothgar_the_conqueror.jpg new file mode 100644 index 0000000..ed49f69 Binary files /dev/null and b/images/dukes/duke_08_hrothgar_the_conqueror.jpg differ diff --git a/images/dukes/duke_09_isabella_the_righteous.jpg b/images/dukes/duke_09_isabella_the_righteous.jpg new file mode 100644 index 0000000..04223ed Binary files /dev/null and b/images/dukes/duke_09_isabella_the_righteous.jpg differ diff --git a/images/dukes/duke_10_elsyn_saint_of_shadows.jpg b/images/dukes/duke_10_elsyn_saint_of_shadows.jpg new file mode 100644 index 0000000..bf95fbe Binary files /dev/null and b/images/dukes/duke_10_elsyn_saint_of_shadows.jpg differ diff --git a/images/dukes/duke_11_waryn_lord_of_rogues.jpg b/images/dukes/duke_11_waryn_lord_of_rogues.jpg new file mode 100644 index 0000000..d4ecf86 Binary files /dev/null and b/images/dukes/duke_11_waryn_lord_of_rogues.jpg differ diff --git a/images/dukes/duke_12_pascal_the_gray_hunter.jpg b/images/dukes/duke_12_pascal_the_gray_hunter.jpg new file mode 100644 index 0000000..3058861 Binary files /dev/null and b/images/dukes/duke_12_pascal_the_gray_hunter.jpg differ diff --git a/images/dukes/duke_13_sir_roberts_of_stoneblood.jpg b/images/dukes/duke_13_sir_roberts_of_stoneblood.jpg new file mode 100644 index 0000000..49717cd Binary files /dev/null and b/images/dukes/duke_13_sir_roberts_of_stoneblood.jpg differ diff --git a/images/dukes/duke_14_aguilar_the_gilded_knight.jpg b/images/dukes/duke_14_aguilar_the_gilded_knight.jpg new file mode 100644 index 0000000..e27edc8 Binary files /dev/null and b/images/dukes/duke_14_aguilar_the_gilded_knight.jpg differ diff --git a/images/dukes/duke_15_lekzandr_the_protector.jpg b/images/dukes/duke_15_lekzandr_the_protector.jpg new file mode 100644 index 0000000..6369015 Binary files /dev/null and b/images/dukes/duke_15_lekzandr_the_protector.jpg differ diff --git a/images/dukes/duke_16_node_master_of_swords.jpg b/images/dukes/duke_16_node_master_of_swords.jpg new file mode 100644 index 0000000..2913863 Binary files /dev/null and b/images/dukes/duke_16_node_master_of_swords.jpg differ diff --git a/images/dukes/duke_17_simon_the_unclean.jpg b/images/dukes/duke_17_simon_the_unclean.jpg new file mode 100644 index 0000000..d8385fb Binary files /dev/null and b/images/dukes/duke_17_simon_the_unclean.jpg differ diff --git a/images/dukes/duke_18_gurira_the_guardian.jpg b/images/dukes/duke_18_gurira_the_guardian.jpg new file mode 100644 index 0000000..51e99b6 Binary files /dev/null and b/images/dukes/duke_18_gurira_the_guardian.jpg differ diff --git a/images/dukes/duke_19_drakkenstrike.jpg b/images/dukes/duke_19_drakkenstrike.jpg new file mode 100644 index 0000000..d67e2b9 Binary files /dev/null and b/images/dukes/duke_19_drakkenstrike.jpg differ diff --git a/images/dukes/duke_back.jpg b/images/dukes/duke_back.jpg new file mode 100644 index 0000000..4a15601 Binary files /dev/null and b/images/dukes/duke_back.jpg differ diff --git a/images/dukes/duke_r02c02.jpg b/images/dukes/duke_r02c02.jpg new file mode 100644 index 0000000..417fbea Binary files /dev/null and b/images/dukes/duke_r02c02.jpg differ diff --git a/images/dukes/duke_r02c03.jpg b/images/dukes/duke_r02c03.jpg new file mode 100644 index 0000000..4d8f561 Binary files /dev/null and b/images/dukes/duke_r02c03.jpg differ diff --git a/images/dukes/duke_r02c04.jpg b/images/dukes/duke_r02c04.jpg new file mode 100644 index 0000000..3a04443 Binary files /dev/null and b/images/dukes/duke_r02c04.jpg differ diff --git a/images/dukes/duke_r02c05.jpg b/images/dukes/duke_r02c05.jpg new file mode 100644 index 0000000..047588c Binary files /dev/null and b/images/dukes/duke_r02c05.jpg differ diff --git a/images/dukes/duke_r02c07.jpg b/images/dukes/duke_r02c07.jpg new file mode 100644 index 0000000..5f1464b Binary files /dev/null and b/images/dukes/duke_r02c07.jpg differ diff --git a/images/dukes/duke_r02c08.jpg b/images/dukes/duke_r02c08.jpg new file mode 100644 index 0000000..a0fa063 Binary files /dev/null and b/images/dukes/duke_r02c08.jpg differ diff --git a/images/dukes/duke_r02c09.jpg b/images/dukes/duke_r02c09.jpg new file mode 100644 index 0000000..5754ef6 Binary files /dev/null and b/images/dukes/duke_r02c09.jpg differ diff --git a/images/dukes/duke_r02c10.jpg b/images/dukes/duke_r02c10.jpg new file mode 100644 index 0000000..dff1e2f Binary files /dev/null and b/images/dukes/duke_r02c10.jpg differ diff --git a/images/dukes/duke_r03c01.jpg b/images/dukes/duke_r03c01.jpg new file mode 100644 index 0000000..231faf8 Binary files /dev/null and b/images/dukes/duke_r03c01.jpg differ diff --git a/images/dukes/duke_r03c02.jpg b/images/dukes/duke_r03c02.jpg new file mode 100644 index 0000000..91d618c Binary files /dev/null and b/images/dukes/duke_r03c02.jpg differ diff --git a/images/dukes/duke_r03c03.jpg b/images/dukes/duke_r03c03.jpg new file mode 100644 index 0000000..de25ccf Binary files /dev/null and b/images/dukes/duke_r03c03.jpg differ diff --git a/images/dukes/duke_r03c04.jpg b/images/dukes/duke_r03c04.jpg new file mode 100644 index 0000000..d08d67c Binary files /dev/null and b/images/dukes/duke_r03c04.jpg differ diff --git a/images/monster_01_goblin.jpg b/images/monsters/monster_01_goblin.jpg similarity index 100% rename from images/monster_01_goblin.jpg rename to images/monsters/monster_01_goblin.jpg diff --git a/images/monster_02_goblin.jpg b/images/monsters/monster_02_goblin.jpg similarity index 100% rename from images/monster_02_goblin.jpg rename to images/monsters/monster_02_goblin.jpg diff --git a/images/monster_03_goblin.jpg b/images/monsters/monster_03_goblin.jpg similarity index 100% rename from images/monster_03_goblin.jpg rename to images/monsters/monster_03_goblin.jpg diff --git a/images/monster_04_goblin_mage.jpg b/images/monsters/monster_04_goblin_mage.jpg similarity index 100% rename from images/monster_04_goblin_mage.jpg rename to images/monsters/monster_04_goblin_mage.jpg diff --git a/images/monster_05_goblin_bomber.jpg b/images/monsters/monster_05_goblin_bomber.jpg similarity index 100% rename from images/monster_05_goblin_bomber.jpg rename to images/monsters/monster_05_goblin_bomber.jpg diff --git a/images/monster_06_goblin_king.jpg b/images/monsters/monster_06_goblin_king.jpg similarity index 100% rename from images/monster_06_goblin_king.jpg rename to images/monsters/monster_06_goblin_king.jpg diff --git a/images/monster_07_goblin_mage.jpg b/images/monsters/monster_07_goblin_mage.jpg similarity index 100% rename from images/monster_07_goblin_mage.jpg rename to images/monsters/monster_07_goblin_mage.jpg diff --git a/images/monster_08_skeleton.jpg b/images/monsters/monster_08_skeleton.jpg similarity index 100% rename from images/monster_08_skeleton.jpg rename to images/monsters/monster_08_skeleton.jpg diff --git a/images/monster_09_skeleton.jpg b/images/monsters/monster_09_skeleton.jpg similarity index 100% rename from images/monster_09_skeleton.jpg rename to images/monsters/monster_09_skeleton.jpg diff --git a/images/monster_10_skeleton.jpg b/images/monsters/monster_10_skeleton.jpg similarity index 100% rename from images/monster_10_skeleton.jpg rename to images/monsters/monster_10_skeleton.jpg diff --git a/images/monster_11_flaming_skeleton.jpg b/images/monsters/monster_11_flaming_skeleton.jpg similarity index 100% rename from images/monster_11_flaming_skeleton.jpg rename to images/monsters/monster_11_flaming_skeleton.jpg diff --git a/images/monster_12_flaming_skeleton.jpg b/images/monsters/monster_12_flaming_skeleton.jpg similarity index 100% rename from images/monster_12_flaming_skeleton.jpg rename to images/monsters/monster_12_flaming_skeleton.jpg diff --git a/images/monster_13_death_knight.jpg b/images/monsters/monster_13_death_knight.jpg similarity index 100% rename from images/monster_13_death_knight.jpg rename to images/monsters/monster_13_death_knight.jpg diff --git a/images/monster_14_skeleton_king.jpg b/images/monsters/monster_14_skeleton_king.jpg similarity index 100% rename from images/monster_14_skeleton_king.jpg rename to images/monsters/monster_14_skeleton_king.jpg diff --git a/images/monster_15_treant.jpg b/images/monsters/monster_15_treant.jpg similarity index 100% rename from images/monster_15_treant.jpg rename to images/monsters/monster_15_treant.jpg diff --git a/images/monster_16_treant.jpg b/images/monsters/monster_16_treant.jpg similarity index 100% rename from images/monster_16_treant.jpg rename to images/monsters/monster_16_treant.jpg diff --git a/images/monster_17_treant.jpg b/images/monsters/monster_17_treant.jpg similarity index 100% rename from images/monster_17_treant.jpg rename to images/monsters/monster_17_treant.jpg diff --git a/images/monster_18_bane_spider.jpg b/images/monsters/monster_18_bane_spider.jpg similarity index 100% rename from images/monster_18_bane_spider.jpg rename to images/monsters/monster_18_bane_spider.jpg diff --git a/images/monster_19_bane_spider.jpg b/images/monsters/monster_19_bane_spider.jpg similarity index 100% rename from images/monster_19_bane_spider.jpg rename to images/monsters/monster_19_bane_spider.jpg diff --git a/images/monster_20_ettercap.jpg b/images/monsters/monster_20_ettercap.jpg similarity index 100% rename from images/monster_20_ettercap.jpg rename to images/monsters/monster_20_ettercap.jpg diff --git a/images/monster_21_spider_queen.jpg b/images/monsters/monster_21_spider_queen.jpg similarity index 100% rename from images/monster_21_spider_queen.jpg rename to images/monsters/monster_21_spider_queen.jpg diff --git a/images/monster_22_owlbear.jpg b/images/monsters/monster_22_owlbear.jpg similarity index 100% rename from images/monster_22_owlbear.jpg rename to images/monsters/monster_22_owlbear.jpg diff --git a/images/monster_23_owlbear.jpg b/images/monsters/monster_23_owlbear.jpg similarity index 100% rename from images/monster_23_owlbear.jpg rename to images/monsters/monster_23_owlbear.jpg diff --git a/images/monster_24_owlbear.jpg b/images/monsters/monster_24_owlbear.jpg similarity index 100% rename from images/monster_24_owlbear.jpg rename to images/monsters/monster_24_owlbear.jpg diff --git a/images/monster_25_giant.jpg b/images/monsters/monster_25_giant.jpg similarity index 100% rename from images/monster_25_giant.jpg rename to images/monsters/monster_25_giant.jpg diff --git a/images/monster_26_giant.jpg b/images/monsters/monster_26_giant.jpg similarity index 100% rename from images/monster_26_giant.jpg rename to images/monsters/monster_26_giant.jpg diff --git a/images/monster_27_satyr_mage.jpg b/images/monsters/monster_27_satyr_mage.jpg similarity index 100% rename from images/monster_27_satyr_mage.jpg rename to images/monsters/monster_27_satyr_mage.jpg diff --git a/images/monster_28_troll.jpg b/images/monsters/monster_28_troll.jpg similarity index 100% rename from images/monster_28_troll.jpg rename to images/monsters/monster_28_troll.jpg diff --git a/images/monster_29_dire_bear.jpg b/images/monsters/monster_29_dire_bear.jpg similarity index 100% rename from images/monster_29_dire_bear.jpg rename to images/monsters/monster_29_dire_bear.jpg diff --git a/images/monster_30_dire_bear.jpg b/images/monsters/monster_30_dire_bear.jpg similarity index 100% rename from images/monster_30_dire_bear.jpg rename to images/monsters/monster_30_dire_bear.jpg diff --git a/images/monster_31_dire_bear.jpg b/images/monsters/monster_31_dire_bear.jpg similarity index 100% rename from images/monster_31_dire_bear.jpg rename to images/monsters/monster_31_dire_bear.jpg diff --git a/images/monster_32_orc_warrior.jpg b/images/monsters/monster_32_orc_warrior.jpg similarity index 100% rename from images/monster_32_orc_warrior.jpg rename to images/monsters/monster_32_orc_warrior.jpg diff --git a/images/monster_33_orc_warrior.jpg b/images/monsters/monster_33_orc_warrior.jpg similarity index 100% rename from images/monster_33_orc_warrior.jpg rename to images/monsters/monster_33_orc_warrior.jpg diff --git a/images/monster_34_orc_batrider.jpg b/images/monsters/monster_34_orc_batrider.jpg similarity index 100% rename from images/monster_34_orc_batrider.jpg rename to images/monsters/monster_34_orc_batrider.jpg diff --git a/images/monster_35_orc_chieftain.jpg b/images/monsters/monster_35_orc_chieftain.jpg similarity index 100% rename from images/monster_35_orc_chieftain.jpg rename to images/monsters/monster_35_orc_chieftain.jpg diff --git a/images/monster_back.jpg b/images/monsters/monster_back.jpg similarity index 100% rename from images/monster_back.jpg rename to images/monsters/monster_back.jpg diff --git a/images/starters/starter_01_peasant.jpg b/images/starters/starter_01_peasant.jpg new file mode 100644 index 0000000..c2a66a2 Binary files /dev/null and b/images/starters/starter_01_peasant.jpg differ diff --git a/images/starters/starter_02_knight.jpg b/images/starters/starter_02_knight.jpg new file mode 100644 index 0000000..5c597b1 Binary files /dev/null and b/images/starters/starter_02_knight.jpg differ diff --git a/images/starters/starter_back.jpg b/images/starters/starter_back.jpg new file mode 100644 index 0000000..bece315 Binary files /dev/null and b/images/starters/starter_back.jpg differ diff --git a/requirements.txt b/requirements.txt index b505ee5..cccf745 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,4 +19,8 @@ shortuuid>=1.0.0 # Web framework for API server fastapi>=0.104.0 uvicorn>=0.24.0 +websockets>=13.0 + +# Image processing (used by split_domains.py and similar scripts) +Pillow>=10.0.0 diff --git a/server.py b/server.py index 895f7f0..504abe4 100644 --- a/server.py +++ b/server.py @@ -4,11 +4,12 @@ FastAPI server for VCK Online - Development/testing server Simple REST API to replace the socket-based protocol """ +import re import time import uuid from pathlib import Path from typing import Dict, List, Optional -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse @@ -20,9 +21,132 @@ import json _REPO_ROOT = Path(__file__).resolve().parent _DEV_CLIENT_INDEX = _REPO_ROOT / "static" / "dev-client" / "index.html" +_GAME_CLIENT_INDEX = _REPO_ROOT / "static" / "game" / "index.html" + +# Card image directories — keyed by the singular type name used in filenames +_CARD_IMAGE_DIRS: Dict[str, Path] = { + "monster": _REPO_ROOT / "images" / "monsters", + "citizen": _REPO_ROOT / "images" / "citizens", + "domain": _REPO_ROOT / "images" / "domains", + "duke": _REPO_ROOT / "images" / "dukes", + "starter": _REPO_ROOT / "images" / "starters", +} +_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp"} app = FastAPI(title="VCK Online API", description="Development server for Valeria Card Kingdoms Online") + +class ConnectionManager: + def __init__(self): + self._conns: Dict[str, List[tuple]] = {} # game_id -> [(ws, player_id)] + + async def connect(self, game_id: str, websocket: WebSocket, player_id: Optional[str] = None): + await websocket.accept() + self._conns.setdefault(game_id, []).append((websocket, player_id)) + + def disconnect(self, game_id: str, websocket: WebSocket): + self._conns[game_id] = [ + (ws, pid) for ws, pid in self._conns.get(game_id, []) if ws is not websocket + ] + + async def broadcast(self, game_id: str, game): + conns = list(self._conns.get(game_id, [])) + dead = [] + for ws, pid in conns: + try: + await ws.send_json({"type": "state", "state": _serialize_game_for_player(game, pid)}) + except Exception: + dead.append(ws) + if dead: + self._conns[game_id] = [(ws, pid) for ws, pid in conns if ws not in dead] + + +manager = ConnectionManager() + + +class LobbyWsManager: + """Push lobby snapshots to subscribed browsers (personalized by optional player_id).""" + + def __init__(self): + self._connections = {} # WebSocket -> Optional[player_id] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self._connections[websocket] = None + + def disconnect(self, websocket: WebSocket): + self._connections.pop(websocket, None) + + def identify(self, websocket: WebSocket, player_id: Optional[str]): + if websocket in self._connections: + self._connections[websocket] = player_id or None + + async def send_snapshot(self, websocket: WebSocket): + pid = self._connections.get(websocket) + try: + payload = build_lobby_status_dict(pid) + await websocket.send_json({"type": "lobby_status", **payload}) + except Exception: + self.disconnect(websocket) + + async def broadcast_lobby(self): + dead = [] + for ws in list(self._connections.keys()): + pid = self._connections.get(ws) + try: + payload = build_lobby_status_dict(pid) + await ws.send_json({"type": "lobby_status", **payload}) + except Exception: + dead.append(ws) + for ws in dead: + self.disconnect(ws) + + async def broadcast_game_started(self, game_id: str, player_ids: List[str]): + dead = [] + msg = {"type": "game_started", "game_id": game_id, "player_ids": list(player_ids)} + for ws in list(self._connections.keys()): + try: + await ws.send_json(msg) + except Exception: + dead.append(ws) + for ws in dead: + self.disconnect(ws) + + +lobby_ws_manager = LobbyWsManager() + + +def build_lobby_status_dict(player_id: Optional[str] = None): + """Lobby list + active game count + optional in_game/game_id for this player.""" + current_time = time.time() + global lobby + lobby = [p for p in lobby if current_time - p.last_active_time <= 60] + + lobby_data = [] + for member in lobby: + lobby_data.append({ + "player_id": member.player_id, + "name": member.name, + "is_ready": member.is_ready, + "debug_starting_resources": bool(getattr(member, "debug_starting_resources", False)), + }) + + response = { + "lobby": lobby_data, + "game_count": sum(1 for g in games.values() if getattr(g, "phase", None) != "game_over"), + } + + if player_id: + for gamer in gamers: + if gamer.player_id == player_id: + response["in_game"] = True + response["game_id"] = gamer.game_id + return response + + response["in_game"] = False + return response + + # CORS middleware for web client app.add_middleware( CORSMiddleware, @@ -110,6 +234,7 @@ async def join_lobby(request: JoinLobbyRequest): player = LobbyMember(request.name, player_id) player.last_active_time = time.time() lobby.append(player) + await lobby_ws_manager.broadcast_lobby() return {"player_id": player_id, "message": "Joined lobby"} @@ -120,6 +245,7 @@ async def rename_player(request: RenameRequest): if player.player_id == request.player_id: player.name = request.name player.last_active_time = time.time() + await lobby_ws_manager.broadcast_lobby() return {"message": "Player renamed"} raise HTTPException(status_code=404, detail="Player not found in lobby") @@ -129,6 +255,7 @@ async def leave_lobby(player_id: str): """Leave the lobby""" global lobby lobby = [p for p in lobby if p.player_id != player_id] + await lobby_ws_manager.broadcast_lobby() return {"message": "Left lobby"} @@ -176,6 +303,9 @@ async def set_ready(request: ReadyRequest): if getattr(new_game, "phase", None) == "action": break games[new_game_id] = new_game + pid_list = [g.player_id for g in game_gamers] + await lobby_ws_manager.broadcast_game_started(new_game_id, pid_list) + await lobby_ws_manager.broadcast_lobby() return { "message": "Game started", "game_id": new_game_id, @@ -184,6 +314,7 @@ async def set_ready(request: ReadyRequest): except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to create game: {str(e)}") + await lobby_ws_manager.broadcast_lobby() return {"message": "Player ready", "all_ready": ready_count == len(lobby)} raise HTTPException(status_code=404, detail="Player not found in lobby") @@ -196,6 +327,7 @@ async def set_unready(request: ReadyRequest): if player.player_id == request.player_id: player.is_ready = False player.last_active_time = time.time() + await lobby_ws_manager.broadcast_lobby() return {"message": "Player unready"} raise HTTPException(status_code=404, detail="Player not found in lobby") @@ -203,35 +335,27 @@ async def set_unready(request: ReadyRequest): @app.get("/api/lobby/status") async def get_lobby_status(player_id: Optional[str] = None): """Get lobby status. If player_id provided, also check if player is in a game.""" - # Clean up inactive players (60 seconds) - current_time = time.time() - global lobby - lobby = [p for p in lobby if current_time - p.last_active_time <= 60] - - lobby_data = [] - for member in lobby: - lobby_data.append({ - "player_id": member.player_id, - "name": member.name, - "is_ready": member.is_ready, - "debug_starting_resources": bool(getattr(member, "debug_starting_resources", False)), - }) - - response = { - "lobby": lobby_data, - "game_count": len(games) - } - - # Check if player is in a game - if player_id: - for gamer in gamers: - if gamer.player_id == player_id: - response["in_game"] = True - response["game_id"] = gamer.game_id - return response - - response["in_game"] = False - return response + return build_lobby_status_dict(player_id) + + +@app.websocket("/ws/lobby") +async def ws_lobby(websocket: WebSocket): + await lobby_ws_manager.connect(websocket) + await lobby_ws_manager.send_snapshot(websocket) + try: + while True: + raw = await websocket.receive_text() + try: + data = json.loads(raw) + except json.JSONDecodeError: + continue + if data.get("type") == "identify": + lobby_ws_manager.identify(websocket, data.get("player_id")) + await lobby_ws_manager.send_snapshot(websocket) + except WebSocketDisconnect: + pass + finally: + lobby_ws_manager.disconnect(websocket) # Game endpoints @@ -253,7 +377,7 @@ def _serialize_game_for_player(game, viewer_player_id: Optional[str]): if not isinstance(p, dict): continue pid = p.get("player_id") - if not viewer_player_id or pid != viewer_player_id: + if viewer_player_id is None or str(pid) != str(viewer_player_id): # Hide opponent (and spectator) dukes. p["owned_dukes"] = [] @@ -414,7 +538,8 @@ async def perform_game_action(game_id: str, request: GameActionRequest): else: raise HTTPException(status_code=400, detail=f"Unknown action type: {request.action_type}") - # Return updated game state + # Push updated state to all WebSocket subscribers for this game. + await manager.broadcast(game_id, game) return {"message": "Action performed", "game_state": _serialize_game_for_player(game, request.player_id)} except HTTPException: @@ -446,16 +571,52 @@ async def startup_event(): asyncio.create_task(cleanup()) +# ── Card image lookup ──────────────────────────────────────────────────────── +@app.get("/card-image/{card_type}/{card_id}") +async def card_image(card_type: str, card_id: int): + """Return the card image matched by type + numeric ID prefix.""" + dir_path = _CARD_IMAGE_DIRS.get(card_type) + if not dir_path or not dir_path.exists(): + raise HTTPException(status_code=404, detail="Unknown card type") + prefix = f"{card_type}_{card_id:02d}_" + for f in sorted(dir_path.iterdir()): + if f.name.startswith(prefix) and f.suffix.lower() in _IMAGE_EXTS: + return FileResponse(str(f), media_type="image/jpeg") + raise HTTPException(status_code=404, detail="Image not found") + + # Serve static files and simple HTML client try: app.mount("/static", StaticFiles(directory="static"), name="static") -except: - pass # static directory might not exist + app.mount("/images", StaticFiles(directory=str(_REPO_ROOT / "images")), name="images") +except Exception: + pass # static / images directory might not exist + + +@app.websocket("/ws/game/{game_id}") +async def ws_game(websocket: WebSocket, game_id: str, player_id: Optional[str] = None): + game = games.get(game_id) + if not game: + await websocket.accept() + await websocket.send_json({"type": "error", "code": 4004, "message": "Game not found"}) + await websocket.close(code=4004) + return + await manager.connect(game_id, websocket, player_id) + try: + await websocket.send_json({"type": "state", "state": _serialize_game_for_player(game, player_id)}) + while True: + await websocket.receive_text() + except WebSocketDisconnect: + manager.disconnect(game_id, websocket) @app.get("/") -async def root(): - """Simple HTML client for testing""" +async def game_client(): + return FileResponse(_GAME_CLIENT_INDEX, media_type="text/html") + + +@app.get("/debug") +async def debug_client(): return FileResponse(_DEV_CLIENT_INDEX, media_type="text/html") diff --git a/split_domains.py b/split_domains.py new file mode 100644 index 0000000..b931e2f --- /dev/null +++ b/split_domains.py @@ -0,0 +1,39 @@ +""" +Run-once script to split domains.jpg into individual card images. + +Grid: 7 columns x 10 rows (left-to-right, top-to-bottom) +Output: images/domains/domain_r{row}c{col}.jpg (1-indexed) +""" + +from pathlib import Path +from PIL import Image + +SOURCE = Path("images/domains.jpg") +OUT_DIR = Path("images/domains") +COLS = 10 +ROWS = 7 +TRIM = 2 # pixels to shave from each edge after splitting + +OUT_DIR.mkdir(parents=True, exist_ok=True) + +img = Image.open(SOURCE) +W, H = img.size +print(f"Source: {W}x{H}px → cell size before trim: {W//COLS}x{H//ROWS}px") + +cell_w = W / COLS +cell_h = H / ROWS + +for row in range(ROWS): + for col in range(COLS): + left = round(col * cell_w) + top = round(row * cell_h) + right = round((col + 1) * cell_w) + bottom = round((row + 1) * cell_h) + + card = img.crop((left, top, right, bottom)) + card = card.crop((TRIM, TRIM, card.width - TRIM, card.height - TRIM)) + + name = f"domain_r{row+1:02d}c{col+1:02d}.jpg" + card.save(OUT_DIR / name, quality=90) + +print(f"Saved {ROWS * COLS} images to {OUT_DIR}/") diff --git a/split_dukes.py b/split_dukes.py new file mode 100644 index 0000000..acece4c --- /dev/null +++ b/split_dukes.py @@ -0,0 +1,44 @@ +""" +Run-once script to split dukes.jpg and dukes_expansion.jpg into individual card images. +Card cell size is derived from image dimensions / grid, starting from the top-left corner. +""" + +from pathlib import Path +from PIL import Image + +TRIM = 2 + +SOURCES = [ + ("images/dukes.jpg", "images/dukes", 10, 3), + ("images/dukes_expansion.jpg", "images/dukes", 4, 2), +] + +for source_path, out_dir_path, cols, rows in SOURCES: + source = Path(source_path) + out_dir = Path(out_dir_path) + out_dir.mkdir(parents=True, exist_ok=True) + + img = Image.open(source) + W, H = img.size + cell_w = W / cols + cell_h = H / rows + print(f"{source.name}: {W}x{H}px → cell {cell_w:.1f}x{cell_h:.1f}px ({cols}x{rows} grid)") + + for row in range(rows): + for col in range(cols): + left = round(col * cell_w) + top = round(row * cell_h) + right = round((col + 1) * cell_w) + bottom = round((row + 1) * cell_h) + + card = img.crop((left, top, right, bottom)) + card = card.crop((TRIM, TRIM, card.width - TRIM, card.height - TRIM)) + + name = f"duke_r{row+1:02d}c{col+1:02d}.jpg" + out_path = out_dir / name + if out_path.exists(): + stem, suffix = name.rsplit(".", 1) + name = f"{stem}_exp.{suffix}" + card.save(out_dir / name, quality=90) + + print(f" → saved to {out_dir}/") diff --git a/static/dev-client/dev-client.js b/static/dev-client/dev-client.js index 49c3ec1..a1c6b6f 100644 --- a/static/dev-client/dev-client.js +++ b/static/dev-client/dev-client.js @@ -1,5 +1,7 @@ let playerId = localStorage.getItem('playerId') || ''; let currentGameId = localStorage.getItem('gameId') || ''; + /** Avoid duplicate tabs when toggleReady and lobby poll both notice the new game */ + let openedVisualGameTabForGameId = ''; let lastGameState = null; let lastRenderedGameLogKey = null; let finalizeRollInFlight = false; @@ -115,6 +117,14 @@ let playerId = localStorage.getItem('playerId') || ''; wireDiceRigUi(); wireDebugStartingResourcesUi(); wireAutoHarvestUi(); + + function openVisualGameClientTab(gameId) { + if (!gameId || !playerId) return; + if (openedVisualGameTabForGameId === gameId) return; + openedVisualGameTabForGameId = gameId; + const q = new URLSearchParams({ game_id: gameId, player_id: playerId }); + window.open(`${location.origin}/?${q}`, '_blank', 'noopener,noreferrer'); + } async function joinLobby() { const name = document.getElementById('playerName').value; @@ -156,6 +166,7 @@ let playerId = localStorage.getItem('playerId') || ''; if (data.in_game) { html += `

You are in game: ${data.game_id}

`; if (data.game_id && data.game_id !== currentGameId) { + openVisualGameClientTab(data.game_id); currentGameId = data.game_id; localStorage.setItem('gameId', currentGameId); // Fetch immediately when we first learn the game id @@ -190,7 +201,7 @@ let playerId = localStorage.getItem('playerId') || ''; if (data.game_id) { currentGameId = data.game_id; localStorage.setItem('gameId', currentGameId); - alert('Game started! Game ID: ' + data.game_id); + openVisualGameClientTab(data.game_id); // Immediately fetch state so the Game section fills in getGameState(false); } @@ -428,7 +439,9 @@ let playerId = localStorage.getItem('playerId') || ''; boardBtn.textContent = 'Board'; boardBtn.style.left = '50%'; boardBtn.style.top = '50%'; - boardBtn.onclick = () => { openBoardTableau(); }; + boardBtn.onclick = () => { + window.open(`/?game_id=${currentGameId}&player_id=${playerId}`, '_blank'); + }; wrap.appendChild(boardBtn); const cleanPlayers = players.filter(p => p && p.player_id); @@ -555,7 +568,7 @@ let playerId = localStorage.getItem('playerId') || ''; 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 (trimmed === 'choose_player' || trimmed === 'choose_monster_strength' || trimmed === 'domain_self_convert' || trimmed === 'harvest_optional_exchange') return true; if (reqAction === 'standard_action' && (gameState.phase || '') === 'action') return true; return false; } @@ -1226,6 +1239,10 @@ let playerId = localStorage.getItem('playerId') || ''; return renderDomainSelfConvertPrompt(gameState); } + if (reqAction === 'harvest_optional_exchange') { + return renderHarvestOptionalExchangePrompt(gameState); + } + if (reqAction === 'choose_player') { return renderDomainChoosePlayer(gameState); } @@ -1655,6 +1672,79 @@ let playerId = localStorage.getItem('playerId') || ''; } } + function harvestExchangeExplain(command) { + const parts = (command || '').trim().split(/\s+/); + if (parts.length < 5 || parts[0].toLowerCase() !== 'exchange') return (command || '').trim() || 'Optional harvest exchange.'; + const pay = parts[1].toLowerCase(); + const payN = parts[2]; + const gain = parts[3].toLowerCase(); + const gainN = parts[4]; + const labels = { g: 'gold', s: 'strength', m: 'magic', v: 'victory points' }; + return `Pay ${payN} ${labels[pay] || pay}, gain ${gainN} ${labels[gain] || gain}.`; + } + + function renderHarvestOptionalExchangePrompt(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 cmd = (prc?.command || '').toString(); + const explain = harvestExchangeExplain(cmd); + if (!isYou) { + panel.innerHTML = `
+ Waiting on ${escapeHtml(reqId)} — optional citizen harvest exchange. +
`; + return; + } + panel.innerHTML = ` +
+
Harvest: optional exchange
+
${escapeHtml(explain)}
+
+ + +
+
`; + } + + async function sendHarvestExchangeConfirm() { + 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_harvest_exchange' + }) + }); + getGameState(false); + } catch (e) { + console.error(e); + } + } + + async function sendHarvestExchangeSkip() { + 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_harvest_exchange' + }) + }); + getGameState(false); + } catch (e) { + console.error(e); + } + } + function renderDomainChoosePlayer(gameState) { const panel = document.getElementById('choicePanel'); if (!panel) return; diff --git a/static/game/game.js b/static/game/game.js new file mode 100644 index 0000000..7ae2312 --- /dev/null +++ b/static/game/game.js @@ -0,0 +1,3095 @@ +'use strict'; + +// ── URL params ──────────────────────────────────────────────────────────── +const params = new URLSearchParams(location.search); +const GAME_ID = params.get('game_id') || ''; +const PLAYER_ID = params.get('player_id') || ''; + +// ── WebSocket ───────────────────────────────────────────────────────────── +let ws = null; +let reconnectTimer = null; +let concurrentPollTimer = null; +let finalizeRollInFlight = false; +/** Set each render — board card modal reads latest grids / phase */ +let latestGameState = null; + +function connect() { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + ws = new WebSocket(`${proto}://${location.host}/ws/game/${GAME_ID}?player_id=${PLAYER_ID}`); + + ws.onopen = () => { + setConnStatus('ok'); + clearTimeout(reconnectTimer); + }; + + ws.onmessage = evt => { + const msg = JSON.parse(evt.data); + if (msg.type === 'state') render(msg.state); + if (msg.type === 'error') { + setConnStatus('error', msg.message); + ws.close(); + } + }; + + ws.onclose = evt => { + // Code 4004 = game not found; don't retry + if (evt.code === 4004) { + setConnStatus('error', 'Game not found'); + return; + } + setConnStatus('off'); + reconnectTimer = setTimeout(connect, 3000); + }; + + ws.onerror = () => ws.close(); +} + +function setConnStatus(s, detail) { + const el = document.getElementById('conn-status'); + if (s === 'ok') { + el.textContent = '● connected'; + el.className = 'conn-status'; + } else if (s === 'error') { + el.textContent = `● ${detail || 'error'}`; + el.className = 'conn-status disconnected'; + } else { + el.textContent = '● disconnected'; + el.className = 'conn-status disconnected'; + } +} + +// ── Seat assignment ─────────────────────────────────────────────────────── +// Returns array of 5 player-or-null entries for seats 0–4. +// Seat 0 = me (bottom), seats 1–4 = opponents clockwise. +function idsMatch(a, b) { + return String(a ?? '').trim() === String(b ?? '').trim(); +} + +function seatAssignment(state) { + const all = state.player_list || []; + const myIdx = all.findIndex(p => idsMatch(p.player_id, PLAYER_ID)); + const base = myIdx === -1 ? 0 : myIdx; + const n = all.length; + // Spread players evenly around the pentagon. + // Seat 0 = me (bottom flat edge); seats 1–4 go clockwise. + // Fill opponents symmetrically so they appear "across" the table. + const fillOrders = { + 1: [0], + 2: [0, 3], // opponent at upper-left (roughly opposite) + 3: [0, 2, 4], // spread at 144° — upper-right and lower-left + 4: [0, 1, 3, 4], // skip the apex seats, fill flanks first + 5: [0, 1, 2, 3, 4], + }; + const order = (fillOrders[n] || [0, 1, 2, 3, 4]).slice(0, n); + const seats = [null, null, null, null, null]; + order.forEach((seatIdx, i) => { + seats[seatIdx] = all[(base + i) % n]; + }); + return seats; +} + +// ── Tableau sections (domains first … dukes last) ───────────────────────── +function tableauGroupsForPlayer(player) { + const defs = [ + ['Domains', 'owned_domains'], + ['Citizens', 'owned_citizens'], + ['Monsters', 'owned_monsters'], + ['Starters', 'owned_starters'], + ['Dukes', 'owned_dukes'], + ]; + return defs + .map(([label, key]) => ({ label, cards: player[key] || [] })) + .filter(g => g.cards.length > 0); +} + +// ── Main render ─────────────────────────────────────────────────────────── +function render(state) { + latestGameState = state; + const seats = seatAssignment(state); + seats.forEach((player, i) => renderSeat(i, player, state)); + renderCenter(state); + renderGameOver(state); + scheduleBoardLayout(); + syncConcurrentPolling(state); + maybeAutoFinalizeRoll(state); + renderPromptModal(state); +} + +function isActiveTurnForPlayer(player, state) { + const ap = state.active_player_id; + if (ap == null || player == null) return false; + return idsMatch(ap, player.player_id); +} + +// ── Active-turn tableau animation (rotating highlight around card row) ── +function wrapTableauWithTurnRing(tableauEl, player, state) { + const host = mk('tableau-ring-host'); + host.appendChild(mk('tableau-ring-sweep')); + host.appendChild(tableauEl); + if (isActiveTurnForPlayer(player, state)) host.classList.add('is-active-turn'); + return host; +} + +// ── Seat renderer ───────────────────────────────────────────────────────── +function renderSeat(idx, player, state) { + const el = document.getElementById(`seat-${idx}`); + el.innerHTML = ''; + el.style.display = 'block'; // always visible — empty seats show as ghost + + if (!player) { + el.classList.add('seat-empty'); + const ghost = mk('seat-ghost-label'); + ghost.textContent = `Seat ${idx}`; + el.appendChild(ghost); + return; + } + el.classList.remove('seat-empty'); + + const inner = mk('seat-inner'); + inner.appendChild(makeHeader(player, state)); + + if (idx === 0) { + const tableau = mk('tableau-cards'); + const groups = tableauGroupsForPlayer(player); + + groups.forEach(g => { + const grp = mk('card-group'); + const lbl = mk('card-group-label'); + lbl.textContent = g.label; + grp.appendChild(lbl); + const grouped = groupCardsForTableau(g.cards); + if (grouped) { + grouped.forEach(({ card, count }) => grp.appendChild(makeTableauStack(card, count, 'full'))); + } else { + g.cards.forEach(c => grp.appendChild(makeTableauStack(c, 1, 'full'))); + } + tableau.appendChild(grp); + }); + inner.appendChild(wrapTableauWithTurnRing(tableau, player, state)); + } else { + const tableau = mk('tableau-cards'); + const groups = tableauGroupsForPlayer(player); + + groups.forEach(g => { + const grp = mk('card-group'); + const lbl = mk('card-group-label'); + lbl.textContent = g.label; + grp.appendChild(lbl); + const grouped = groupCardsForTableau(g.cards); + if (grouped) { + grouped.forEach(({ card, count }) => grp.appendChild(makeTableauStack(card, count, 'mini'))); + } else { + g.cards.forEach(c => grp.appendChild(makeTableauStack(c, 1, 'mini'))); + } + tableau.appendChild(grp); + }); + inner.appendChild(wrapTableauWithTurnRing(tableau, player, state)); + } + + el.appendChild(inner); +} + +// ── Center board ────────────────────────────────────────────────────────── +function renderCenter(state) { + const el = document.getElementById('zone-center'); + el.innerHTML = ''; + + const body = mk('center-board-body'); + body.appendChild(makeInfoBar(state)); + + const scrollArea = mk('center-board-scroll'); + scrollArea.appendChild(makeGridSection('Monsters', state.monster_grid || [], 'monster', 5, 'board-monsters')); + scrollArea.appendChild(makeCitizenSection(state.citizen_grid || [])); + scrollArea.appendChild(makeGridSection('Domains', state.domain_grid || [], 'domain', 5, 'board-domains')); + body.appendChild(scrollArea); + + el.appendChild(body); + el.appendChild(makeGameLog(state)); +} + +const BOARD_TOP_MARGIN_PX = 8; +const BOARD_GAP_ABOVE_TABLEAU_PX = 10; + +/** Vertical wheel scrolls opponent tableau rows horizontally (they overflow-x). */ +function initOpponentTableauWheelScroll() { + const board = document.getElementById('board'); + if (!board || board.dataset.tableauWheelBound) return; + board.dataset.tableauWheelBound = '1'; + board.addEventListener( + 'wheel', + e => { + const row = e.target.closest('.tableau-cards'); + if (!row) return; + const seat = row.closest('.seat'); + if (!seat || seat.classList.contains('seat-0') || seat.classList.contains('seat-empty')) return; + if (row.scrollWidth <= row.clientWidth + 1) return; + e.preventDefault(); + row.scrollLeft += e.deltaY; + }, + { passive: false }, + ); +} +const REPULSE_GAP_PX = 12; +const REPULSE_MAX_ITER = 16; + +function rectsOverlap(a, b) { + return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +} + +function inflateRect(r, pad) { + return { + left: r.left - pad, + top: r.top - pad, + right: r.right + pad, + bottom: r.bottom + pad, + }; +} + +/** Shift opponent seats toward screen edges from pentagon anchors (scales with viewport width). */ +function layoutSeatEdgeSpread() { + const boardEl = document.getElementById('board'); + if (!boardEl) return; + const vw = window.innerWidth; + const spread = Math.min(220, Math.max(0, (vw - 520) * 0.078)); + boardEl.style.setProperty('--seat-edge-spread', `${spread}px`); +} + +function layoutCenterBoard() { + const zone = document.getElementById('zone-center'); + const seat0 = document.getElementById('seat-0'); + if (!zone || !seat0) return; + + const seatRect = seat0.getBoundingClientRect(); + const vh = window.innerHeight; + + const bottomPx = vh - seatRect.top + BOARD_GAP_ABOVE_TABLEAU_PX; + zone.style.bottom = `${Math.max(0, bottomPx)}px`; + zone.style.top = 'auto'; + + zone.style.left = '50%'; + zone.style.right = 'auto'; + zone.style.transform = 'translateX(-50%)'; + + const spaceAbove = seatRect.top - BOARD_GAP_ABOVE_TABLEAU_PX - BOARD_TOP_MARGIN_PX; + const boardViewportCap = vh * 0.75; + const maxH = Math.min(boardViewportCap, Math.max(spaceAbove, 80)); + zone.style.maxHeight = `${Math.floor(maxH)}px`; +} + +/** Push opponent seats horizontally away from the centered board (priority) and apart from each other. */ +function layoutSeatRepulsion() { + const board = document.getElementById('zone-center'); + if (!board) return; + + const vw = window.innerWidth; + const clamp = x => Math.max(-vw * 0.38, Math.min(vw * 0.38, x)); + + function occupiedSeat(idx) { + const el = document.getElementById(`seat-${idx}`); + return el && !el.classList.contains('seat-empty') ? el : null; + } + + for (let i = 1; i <= 4; i++) { + const el = document.getElementById(`seat-${i}`); + if (!el) continue; + if (el.classList.contains('seat-empty')) { + el.style.setProperty('--seat-nudge-x', '0px'); + el.style.setProperty('--seat-nudge-y', '0px'); + } + } + + const nudge = { 1: { x: 0, y: 0 }, 2: { x: 0, y: 0 }, 3: { x: 0, y: 0 }, 4: { x: 0, y: 0 } }; + + function applyNudges() { + for (let idx = 1; idx <= 4; idx++) { + const el = document.getElementById(`seat-${idx}`); + if (!el || el.classList.contains('seat-empty')) continue; + nudge[idx].x = clamp(nudge[idx].x); + nudge[idx].y = Math.max(-100, Math.min(100, nudge[idx].y)); + el.style.setProperty('--seat-nudge-x', `${nudge[idx].x}px`); + el.style.setProperty('--seat-nudge-y', `${nudge[idx].y}px`); + } + } + + function separatePair(idxA, idxB) { + const elA = occupiedSeat(idxA); + const elB = occupiedSeat(idxB); + if (!elA || !elB) return false; + const ra = elA.getBoundingClientRect(); + const rb = elB.getBoundingClientRect(); + if (!rectsOverlap(ra, rb)) return false; + + const ovx = Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left); + const ovy = Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top); + if (ovx <= 0 || ovy <= 0) return false; + + const acx = (ra.left + ra.right) / 2; + const bcx = (rb.left + rb.right) / 2; + const push = (ovx + REPULSE_GAP_PX) * 0.48; + if (acx < bcx) { + nudge[idxA].x -= push; + nudge[idxB].x += push; + } else { + nudge[idxA].x += push; + nudge[idxB].x -= push; + } + + const acy = (ra.top + ra.bottom) / 2; + const bcy = (rb.top + rb.bottom) / 2; + const ovyPush = (ovy + REPULSE_GAP_PX) * 0.22; + if (Math.abs(ovy) > 8 && ovx < ovy * 1.4) { + if (acy < bcy) { + nudge[idxA].y -= ovyPush; + nudge[idxB].y += ovyPush; + } else { + nudge[idxA].y += ovyPush; + nudge[idxB].y -= ovyPush; + } + } + return true; + } + + for (let iter = 0; iter < REPULSE_MAX_ITER; iter++) { + applyNudges(); + + const boardRect = board.getBoundingClientRect(); + const paddedBoard = inflateRect(boardRect, REPULSE_GAP_PX); + + let adjusted = false; + + [1, 2, 3, 4].forEach(idx => { + const el = occupiedSeat(idx); + if (!el) return; + const r = el.getBoundingClientRect(); + if (!rectsOverlap(r, paddedBoard)) return; + + const bcx = boardRect.left + boardRect.width / 2; + const scx = r.left + r.width / 2; + const ovx = Math.min(r.right, paddedBoard.right) - Math.max(r.left, paddedBoard.left); + const ovy = Math.min(r.bottom, paddedBoard.bottom) - Math.max(r.top, paddedBoard.top); + if (ovx <= 0 || ovy <= 0) return; + + const mag = Math.min(ovx + REPULSE_GAP_PX * 0.5, 140); + const dir = scx < bcx ? -1 : 1; + nudge[idx].x += dir * mag * 0.62; + + const scy = r.top + r.height / 2; + const bcy = boardRect.top + boardRect.height / 2; + const vyMag = Math.min(ovy + REPULSE_GAP_PX * 0.35, 90); + const vyDir = scy < bcy ? -1 : 1; + nudge[idx].y += vyDir * vyMag * 0.28; + + adjusted = true; + }); + + if (separatePair(2, 3)) adjusted = true; + if (separatePair(1, 2)) adjusted = true; + if (separatePair(3, 4)) adjusted = true; + if (separatePair(1, 4)) adjusted = true; + + if (!adjusted) break; + } + + applyNudges(); +} + +function scheduleBoardLayout() { + layoutSeatEdgeSpread(); + layoutCenterBoard(); + requestAnimationFrame(() => { + layoutSeatRepulsion(); + }); +} + +function canOfferTakeResourceAction(state) { + if (!PLAYER_ID || !state) return false; + if ((state.phase || '').toString() !== 'action') return false; + const req = state.action_required || {}; + if ((req.action || '').toString() !== 'standard_action') return false; + const reqId = req.id || ''; + if (!reqId || idsMatch(reqId, state.game_id)) return false; + if (!idsMatch(reqId, PLAYER_ID)) return false; + return Number(state.actions_remaining || 0) > 0; +} + +function makeInfoBar(state) { + const bar = mk('info-bar'); + + const phase = mk('phase-label'); + phase.textContent = fmtPhase(state.phase); + bar.appendChild(phase); + + const tn = mk('turn-label'); + tn.textContent = `Turn ${state.turn_number || 1}`; + bar.appendChild(tn); + + const active = (state.player_list || []).find(p => p.player_id === state.active_player_id); + if (active && active.player_id !== PLAYER_ID) { + const who = mk('turn-label'); + who.textContent = `— ${active.name}'s turn`; + bar.appendChild(who); + } + + if (state.end_game_triggered) { + const eg = mk('turn-label'); + eg.textContent = '⚑ Final round'; + eg.style.color = 'var(--gold)'; + bar.appendChild(eg); + } + + if (canOfferTakeResourceAction(state)) { + const takeWrap = mk('info-bar-take-resource'); + const takeLbl = mk('info-bar-take-label'); + const nAct = Number(state.actions_remaining || 0); + takeLbl.textContent = `Spend action (${nAct} left)`; + takeWrap.appendChild(takeLbl); + const takeBtns = mk('info-bar-take-buttons'); + ['gold', 'strength', 'magic'].forEach(r => { + const lab = r === 'gold' ? 'G' : r === 'strength' ? 'S' : 'M'; + takeBtns.appendChild(promptButton(`+1 ${lab}`, () => postGameAction({ + player_id: PLAYER_ID, + action_type: 'take_resource', + resource: r, + }))); + }); + takeWrap.appendChild(takeBtns); + bar.appendChild(takeWrap); + } + + const dice = mk('dice-display'); + if (state.die_one != null) { + dice.appendChild(makeDie(state.die_one)); + dice.appendChild(makeDie(state.die_two)); + const sum = mk('die-sum'); + sum.textContent = `= ${state.die_sum}`; + dice.appendChild(sum); + } + bar.appendChild(dice); + + return bar; +} + +function makeGridSection(label, grid, _type, _cols, extraClass) { + const sec = mk('center-section' + (extraClass ? ` ${extraClass}` : '')); + const lbl = mk('section-label'); + lbl.textContent = label; + sec.appendChild(lbl); + const row = mk('grid-row'); + grid.forEach(stack => row.appendChild(makeStack(stack))); + sec.appendChild(row); + return sec; +} + +function makeCitizenSection(grid) { + const sec = mk('center-section board-citizens'); + const lbl = mk('section-label'); + lbl.textContent = 'Citizens'; + sec.appendChild(lbl); + + const row1 = mk('grid-row citizen-row-first'); + const row2 = mk('grid-row citizen-row-second'); + grid.slice(0, 5).forEach(s => row1.appendChild(makeStack(s))); + grid.slice(5).forEach(s => row2.appendChild(makeStack(s))); + sec.appendChild(row1); + if (grid.length > 5) sec.appendChild(row2); + return sec; +} + +function makeStack(stack) { + if (!stack || stack.length === 0) { + return mk('card-slot-empty'); + } + const wrap = mk('grid-stack'); + wrap.appendChild(makeCard(stack[stack.length - 1], 'grid')); + if (stack.length > 1) { + const badge = mk('stack-depth'); + badge.textContent = `×${stack.length}`; + wrap.appendChild(badge); + } + return wrap; +} + +// Group identical owned cards (same logic as dev-client tableau); citizens also key on is_flipped. +function groupCardsForTableau(cards) { + const arr = Array.isArray(cards) ? cards : []; + const map = new Map(); + arr.forEach(c => { + 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 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) }); + }); + if (map.size === 0 && arr.length) return null; + return Array.from(map.values()).sort((a, b) => { + if (a.sortName < b.sortName) return -1; + if (a.sortName > b.sortName) return 1; + if (a.sortId < b.sortId) return -1; + if (a.sortId > b.sortId) return 1; + return 0; + }); +} + +// One card image with optional ×N badge (matches center-board stacks). +function makeTableauStack(card, count, mode) { + const wrap = mk('grid-stack'); + wrap.appendChild(makeCard(card, mode)); + if (count > 1) { + const badge = mk('stack-depth'); + badge.textContent = `×${count}`; + wrap.appendChild(badge); + } + return wrap; +} + +function makeGameLog(state) { + const log = mk('game-log'); + (state.game_log || []).slice().reverse().forEach(entry => { + const line = mk('log-entry'); + line.textContent = entry.msg ?? entry; + log.appendChild(line); + }); + return log; +} + +function makeDie(val) { + const d = mk('die'); + d.textContent = val; + return d; +} + +// ── Player header / tableau score strip ────────────────────────────────── +const TABLEAU_RESOURCE_ICONS = { + gold: '/images/gold_icon.jpg', + magic: '/images/magic_icon.png', + strength: '/images/strength_icon.png', +}; + +function makeResourceScorePill(cls, val, fullName, iconSrc) { + const pill = mk('score-pill ' + cls); + const n = Number(val ?? 0); + const tip = `${n} times ${fullName}`; + pill.title = tip; + pill.setAttribute('aria-label', tip); + pill.appendChild(document.createTextNode(String(n))); + pill.appendChild(document.createTextNode(' \u00D7 ')); + const img = document.createElement('img'); + img.className = 'score-pill-resource-icon'; + img.alt = ''; + img.src = iconSrc; + pill.appendChild(img); + return pill; +} + +function makeVpScorePill(val) { + const pill = mk('score-pill victory'); + const n = Number(val ?? 0); + const tip = `${n} times Victory Points`; + pill.textContent = `${n} VP`; + pill.title = tip; + pill.setAttribute('aria-label', tip); + return pill; +} + +/** Inline resource display for card modals (matches tableau icons × coloring). */ +function makeModalResourceInline(kind, num, cls, leadingPlus) { + const wrap = document.createElement('span'); + wrap.className = cls ? `modal-stat-value ${cls} modal-resource-inline` : 'modal-stat-value modal-resource-inline'; + if (leadingPlus) wrap.appendChild(document.createTextNode('+')); + wrap.appendChild(document.createTextNode(String(num))); + wrap.appendChild(document.createTextNode(' \u00D7 ')); + const img = document.createElement('img'); + img.className = 'modal-resource-icon'; + img.alt = ''; + img.src = TABLEAU_RESOURCE_ICONS[kind]; + const names = { gold: 'Gold', strength: 'Strength', magic: 'Magic' }; + const n = Number(num); + const tip = `${n} times ${names[kind]}`; + wrap.title = tip; + wrap.setAttribute('aria-label', tip); + wrap.appendChild(img); + return wrap; +} + +function makeModalVpValue(num, cls, leadingPlus) { + const span = document.createElement('span'); + span.className = cls ? `modal-stat-value ${cls}` : 'modal-stat-value'; + const n = Number(num); + span.textContent = `${leadingPlus ? '+' : ''}${n} VP`; + span.title = `${n} times Victory Points`; + return span; +} + +function createModalStatValueEl(row) { + if (row.resource === 'gold' || row.resource === 'strength' || row.resource === 'magic') { + return makeModalResourceInline(row.resource, row.value, row.cls, row.leadingPlus); + } + if (row.resource === 'vp') { + return makeModalVpValue(row.value, row.cls, row.leadingPlus); + } + const v = document.createElement('span'); + v.className = row.cls ? `modal-stat-value ${row.cls}` : 'modal-stat-value'; + v.textContent = row.value; + return v; +} + +function appendCardModalStatRows(infoEl, card) { + buildCardStats(card).forEach(row => { + const r = mk('modal-stat-row'); + const l = document.createElement('span'); + l.className = 'modal-stat-label'; + l.textContent = row.label; + r.appendChild(l); + r.appendChild(createModalStatValueEl(row)); + infoEl.appendChild(r); + }); +} + +function makeHeader(player, state) { + const h = mk('player-header'); + + const name = mk('player-name'); + if (isActiveTurnForPlayer(player, state)) name.classList.add('is-active'); + if (player.is_first) { + name.classList.add('is-first'); + const star = mk('player-first-star'); + star.textContent = '★'; + star.title = 'This player went first'; + star.setAttribute('aria-label', 'This player went first'); + name.appendChild(star); + } + name.appendChild(document.createTextNode(player.name)); + h.appendChild(name); + + h.appendChild(makeResourceScorePill('gold', player.gold_score, 'Gold', TABLEAU_RESOURCE_ICONS.gold)); + h.appendChild(makeResourceScorePill('strength', player.strength_score, 'Strength', TABLEAU_RESOURCE_ICONS.strength)); + h.appendChild(makeResourceScorePill('magic', player.magic_score, 'Magic', TABLEAU_RESOURCE_ICONS.magic)); + h.appendChild(makeVpScorePill(player.victory_score)); + + return h; +} + +// ── Card factory ────────────────────────────────────────────────────────── +function cardImageUrl(card) { + if (card.monster_id !== undefined) return `/card-image/monster/${card.monster_id}`; + if (card.citizen_id !== undefined) return `/card-image/citizen/${card.citizen_id}`; + if (card.domain_id !== undefined) return `/card-image/domain/${card.domain_id}`; + if (card.duke_id !== undefined) return `/card-image/duke/${card.duke_id}`; + if (card.starter_id !== undefined) return `/card-image/starter/${card.starter_id}`; + return null; +} + +function _appendCardText(el, card, mode) { + const name = mk('card-name'); + name.textContent = card.name || '?'; + el.appendChild(name); + if (mode !== 'compact') { + const sub = cardSub(card); + if (sub) { const s = mk('card-sub'); s.textContent = sub; el.appendChild(s); } + const extra = cardExtra(card); + if (extra) { const e = mk('card-extra'); e.textContent = extra; el.appendChild(e); } + } +} + +function makeCard(card, mode) { + const el = mk('card ' + cardClass(card)); + el.dataset.card = JSON.stringify(card); + if (card.is_flipped) el.classList.add('flipped'); + + const imgUrl = mode !== 'compact' ? cardImageUrl(card) : null; + + if (imgUrl) { + el.classList.add('card-has-image'); + el.setAttribute('role', 'img'); + el.setAttribute('aria-label', card.name || 'Card'); + + const img = document.createElement('img'); + img.className = 'card-img'; + img.alt = ''; + img.onerror = () => { + el.classList.remove('card-has-image'); + el.removeAttribute('role'); + el.removeAttribute('aria-label'); + el.innerHTML = ''; + _appendCardText(el, card, mode); + }; + img.src = imgUrl; // set src after onerror so handler is registered first + el.appendChild(img); + } else { + _appendCardText(el, card, mode); + } + + return el; +} + +function cardClass(card) { + if (card.exhausted_id !== undefined) return 'card-exhausted'; + if (card.monster_id !== undefined) return 'card-monster'; + if (card.citizen_id !== undefined) return 'card-citizen'; + if (card.domain_id !== undefined) return 'card-domain'; + if (card.duke_id !== undefined) return 'card-duke'; + return 'card-starter'; +} + +function cardSub(card) { + if (card.monster_id !== undefined) { + const parts = []; + if (card.strength_cost) parts.push(`${card.strength_cost} str`); + if (card.magic_cost) parts.push(`${card.magic_cost} mag`); + return parts.length ? `Cost: ${parts.join(' + ')}` : ''; + } + if (card.citizen_id !== undefined || card.domain_id !== undefined) { + return card.gold_cost ? `Cost: ${card.gold_cost}g` : ''; + } + if (card.starter_id !== undefined) { + const m1 = card.roll_match1, m2 = card.roll_match2; + if (m1 && m2 && m1 !== m2) return `Rolls: ${m1}, ${m2}`; + if (m1) return `Roll: ${m1}`; + } + return ''; +} + +function cardExtra(card) { + const parts = []; + if (card.vp_reward) parts.push(`${card.vp_reward} VP`); + if (card.gold_reward) parts.push(`+${card.gold_reward}g`); + if (card.strength_reward) parts.push(`+${card.strength_reward} str`); + if (card.magic_reward) parts.push(`+${card.magic_reward} mag`); + // Domain text (short) + if (!parts.length && card.text) { + const t = card.text.slice(0, 40); + return t.length < card.text.length ? t + '…' : t; + } + return parts.join(' '); +} + +// ── Game over overlay ───────────────────────────────────────────────────── +function renderGameOver(state) { + const existing = document.getElementById('game-over-overlay'); + if (state.phase !== 'game_over' || !state.final_scores) { + if (existing) existing.remove(); + return; + } + if (existing) return; + + const overlay = mk('game-over-overlay'); + overlay.id = 'game-over-overlay'; + + const panel = mk('game-over-panel'); + const title = mk('game-over-title'); + title.textContent = 'Game Over'; + panel.appendChild(title); + + (state.final_scores || []).forEach(s => { + const row = mk('score-row'); + + const rank = mk('rank'); + rank.textContent = `#${s.rank}`; + row.appendChild(rank); + + const name = mk('sname'); + name.textContent = s.name; + row.appendChild(name); + + const total = mk('total'); + total.textContent = `${s.total_vp} VP`; + row.appendChild(total); + + const bd = mk('breakdown'); + bd.textContent = `${s.base_vp} base + ${s.duke_vp} Duke`; + row.appendChild(bd); + + panel.appendChild(row); + }); + + overlay.appendChild(panel); + document.body.appendChild(overlay); +} + +// ── Helpers ─────────────────────────────────────────────────────────────── +function mk(classes) { + const el = document.createElement('div'); + el.className = classes || ''; + return el; +} + +function fmtPhase(phase) { + return { + roll: 'Roll Phase', + harvest: 'Harvest Phase', + action: 'Action Phase', + cleanup: 'Cleanup', + game_over: 'Game Over', + setup: 'Setup', + }[phase] || (phase || ''); +} + +// ── Card hover preview ──────────────────────────────────────────────────── +const _previewEl = document.createElement('img'); +_previewEl.className = 'card-preview'; +document.body.appendChild(_previewEl); + +let _hoverCard = null; +let _hoverTimer = null; +let _pendingRect = null; +let _previewPlacement = 'auto'; + +function previewPlacementForCard(cardEl) { + if (!cardEl.closest('.center-board')) return 'auto'; + if (cardEl.closest('.citizen-row-second')) return 'above'; + if (cardEl.closest('.board-domains')) return 'above'; + if (cardEl.closest('.board-monsters')) return 'below'; + if (cardEl.closest('.citizen-row-first')) return 'below'; + return 'auto'; +} + +_previewEl.onload = () => { + if (_pendingRect) { + positionPreview(_pendingRect, _previewEl.naturalWidth, _previewEl.naturalHeight, _previewPlacement); + } +}; + +function positionPreview(rect, w, h, placement) { + const vw = window.innerWidth; + const vh = window.innerHeight; + const mode = placement != null ? placement : _previewPlacement; + let top; + if (mode === 'below') { + top = rect.bottom + 8; + if (top + h > vh - 8) top = rect.top - h - 8; + } else if (mode === 'above') { + top = rect.top - h - 8; + if (top < 8) top = rect.bottom + 8; + } else { + top = rect.top - h - 8; + if (top < 8) top = rect.bottom + 8; + } + let left = rect.left + rect.width / 2 - w / 2; + left = Math.max(8, Math.min(left, vw - w - 8)); + _previewEl.style.top = top + 'px'; + _previewEl.style.left = left + 'px'; +} + +document.addEventListener('mouseover', e => { + const cardEl = e.target.closest('.card[data-card]'); + if (cardEl === _hoverCard) return; + clearTimeout(_hoverTimer); + _hoverCard = cardEl; + if (!cardEl) { _previewEl.style.display = 'none'; return; } + + const card = JSON.parse(cardEl.dataset.card); + const url = cardImageUrl(card); + if (!url) return; + + _previewPlacement = previewPlacementForCard(cardEl); + + _hoverTimer = setTimeout(() => { + const rect = cardEl.getBoundingClientRect(); + _pendingRect = rect; + _previewEl.style.display = 'block'; + if (_previewEl.src.endsWith(url) && _previewEl.complete && _previewEl.naturalWidth) { + positionPreview(rect, _previewEl.naturalWidth, _previewEl.naturalHeight, _previewPlacement); + } else { + _previewEl.src = url; + } + }, 120); +}); + +document.addEventListener('mouseout', e => { + const cardEl = e.target.closest('.card[data-card]'); + if (!cardEl || cardEl.contains(e.relatedTarget)) return; + clearTimeout(_hoverTimer); + _hoverCard = null; + _previewEl.style.display = 'none'; +}); + +// ── Board market actions (hire / build / slay) ───────────────────────────── +function topOfStack(stack) { + if (!Array.isArray(stack) || stack.length === 0) return null; + return stack[stack.length - 1]; +} + +function canAffordCost(player, cost) { + const G = Number(player?.gold_score || 0); + const S = Number(player?.strength_score || 0); + const M = Number(player?.magic_score || 0); + const goldCost = Number(cost?.gold || 0); + const strengthCost = Number(cost?.strength || 0); + const magicMin = Number(cost?.magicMin || 0); + + const remainingMagic = M - magicMin; + if (remainingMagic < 0) return { ok: false }; + + const deficitGold = Math.max(0, goldCost - G); + const deficitStrength = Math.max(0, strengthCost - S); + + if (goldCost > 0 && deficitGold > 0 && G <= 0) return { ok: false }; + if (strengthCost > 0 && deficitStrength > 0 && S <= 0) return { ok: false }; + + const ok = (deficitGold + deficitStrength) <= remainingMagic; + + const payGold = Math.min(G, goldCost); + const payStrength = Math.min(S, strengthCost); + const payMagic = magicMin + deficitGold + deficitStrength; + return { ok, payGold, payStrength, payMagic, deficitGold, deficitStrength, remainingMagic }; +} + +function ownedNameCount(player, name) { + const target = (name ?? '').toString(); + if (!target) return 0; + const starters = Array.isArray(player?.owned_starters) ? player.owned_starters : []; + const citizens = Array.isArray(player?.owned_citizens) ? player.owned_citizens : []; + let n = 0; + starters.forEach(c => { if ((c?.name ?? '').toString() === target) n += 1; }); + citizens.forEach(c => { if ((c?.name ?? '').toString() === target) n += 1; }); + return n; +} + +function citizenRoleCounts(card) { + const r = card && card.roles; + if (r && typeof r === 'object') { + return { + sn: Number(r.shadow) || 0, + hn: Number(r.holy) || 0, + son: Number(r.soldier) || 0, + wn: Number(r.worker) || 0, + }; + } + return { + sn: Number(card.shadow_count) || 0, + hn: Number(card.holy_count) || 0, + son: Number(card.soldier_count) || 0, + wn: Number(card.worker_count) || 0, + }; +} + +function formatHarvestGSM(card, onTurn) { + const g = onTurn ? 'gold_payout_on_turn' : 'gold_payout_off_turn'; + const s = onTurn ? 'strength_payout_on_turn' : 'strength_payout_off_turn'; + const m = onTurn ? 'magic_payout_on_turn' : 'magic_payout_off_turn'; + const gv = Number(card[g]) || 0; + const sv = Number(card[s]) || 0; + const mv = Number(card[m]) || 0; + return `G ${gv}, S ${sv}, M ${mv}`; +} + +function pushHarvestHints(hints, card) { + const hasOn = card.gold_payout_on_turn !== undefined || card.strength_payout_on_turn !== undefined || card.magic_payout_on_turn !== undefined; + const hasOff = card.gold_payout_off_turn !== undefined || card.strength_payout_off_turn !== undefined || card.magic_payout_off_turn !== undefined; + if (!hasOn && !hasOff) return; + const onStr = formatHarvestGSM(card, true); + const offStr = formatHarvestGSM(card, false); + if (onStr === offStr) { + hints.push(`Harvest: ${onStr} (on & off turn)`); + } else { + hints.push(`Harvest (on turn): ${onStr}`); + hints.push(`Harvest (off turn): ${offStr}`); + } +} + +function cardDetailedRules(card) { + if (!card || typeof card !== 'object') return ''; + const rawText = (card.text ?? '').toString().trim(); + if (rawText) return rawText; + + const parts = []; + pushHarvestHints(parts, card); + 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(); + if (passive) parts.push(`Passive: ${passive}`); + if (activation) parts.push(`Activation: ${activation}`); + + const spOn = (card.special_payout_on_turn ?? '').toString().trim(); + const spOff = (card.special_payout_off_turn ?? '').toString().trim(); + if (spOn) parts.push(`Special (on turn): ${spOn}`); + if (spOff) parts.push(`Special (off turn): ${spOff}`); + + const specialReward = (card.special_reward ?? '').toString().trim(); + const specialCost = (card.special_cost ?? '').toString().trim(); + if (specialReward) parts.push(`Special reward: ${specialReward}`); + if (specialCost) parts.push(`Special cost: ${specialCost}`); + + return parts.join('\n').trim(); +} + +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()); + } + } + 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 findMarketStack(card, state) { + let grid = null; + let idKey = null; + if (card.monster_id != null) { grid = state?.monster_grid; idKey = 'monster_id'; } + else if (card.citizen_id != null) { grid = state?.citizen_grid; idKey = 'citizen_id'; } + else if (card.domain_id != null) { grid = state?.domain_grid; idKey = 'domain_id'; } + else return null; + const stacks = Array.isArray(grid) ? grid : []; + const cid = card[idKey]; + for (let i = 0; i < stacks.length; i++) { + const top = topOfStack(stacks[i]); + if (!top || top[idKey] !== cid) continue; + return { stack: stacks[i], stackIndex: i, top }; + } + return null; +} + +function evaluateMarketCardContext(card, state) { + const phase = (state?.phase || '').toString(); + const req = state?.action_required || {}; + const reqAction = (req?.action || '').toString(); + const reqId = req?.id || ''; + const standardActionPhase = + phase === 'action' && reqAction === 'standard_action' && reqId && reqId !== state?.game_id; + const isYourTurn = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID)); + const actionsRemaining = Number(state?.actions_remaining || 0); + + const players = state?.player_list || []; + const actingPlayer = players.find(p => idsMatch(p.player_id, reqId)) || null; + + const tn = Number(state?.turn_number); + const emeraldActive = actingPlayer ? hasActionEffectFlag(actingPlayer, 'action.emeraldstronghold', tn) : false; + const pratchettActive = actingPlayer ? hasActionEffectFlag(actingPlayer, 'action.pratchettsplateau', tn) : false; + + const loc = state ? findMarketStack(card, state) : null; + let blockReason = ''; + let top = loc ? loc.top : null; + let stackSize = loc ? loc.stack.length : 0; + + if (!state) { + blockReason = 'Game state not loaded.'; + } else if (!loc) { + blockReason = 'This card is not on the market (stacks may have changed).'; + } else if (card.monster_id != null && !top.is_accessible) { + blockReason = 'This monster stack is blocked.'; + } else if (card.citizen_id != null && !top.is_accessible) { + blockReason = 'This citizen stack is blocked.'; + } else if (card.domain_id != null && (!top.is_visible || !top.is_accessible)) { + blockReason = 'This domain cannot be built right now.'; + } + + let evalRes = { ok: false, payGold: 0, payStrength: 0, payMagic: 0 }; + let scaledCost = 0; + let baseCost = 0; + let surcharge = 0; + let effectiveGold = 0; + let pratchettHint = ''; + let dupHint = ''; + let emeraldHint = ''; + + if (actingPlayer && loc && top && !blockReason) { + if (card.citizen_id != null) { + baseCost = Number(top.gold_cost || 0); + surcharge = emeraldActive ? 0 : ownedNameCount(actingPlayer, top.name); + scaledCost = baseCost + surcharge; + evalRes = canAffordCost(actingPlayer, { gold: scaledCost, strength: 0, magicMin: 0 }); + dupHint = surcharge ? `base ${baseCost}g + ${surcharge} duplicate(s)` : ''; + emeraldHint = (!surcharge && emeraldActive) ? 'Emerald Stronghold: no duplicate surcharge.' : ''; + } else if (card.domain_id != null) { + baseCost = Number(top.gold_cost || 0); + effectiveGold = Math.max(0, baseCost - (pratchettActive ? 1 : 0)); + evalRes = canAffordCost(actingPlayer, { gold: effectiveGold, strength: 0, magicMin: 0 }); + pratchettHint = pratchettActive && baseCost !== effectiveGold ? `base ${baseCost}g − 1 (Pratchett's Plateau)` : ''; + } else if (card.monster_id != null) { + evalRes = canAffordCost(actingPlayer, { + gold: 0, + strength: Number(top.strength_cost || 0), + magicMin: Number(top.magic_cost || 0), + }); + } + } + + const canActThisCard = + standardActionPhase && + isYourTurn && + actionsRemaining > 0 && + loc && + !blockReason; + + return { + phase, + standardActionPhase, + isYourTurn, + actionsRemaining, + actingPlayer, + reqId, + emeraldActive, + pratchettActive, + loc, + top, + stackSize, + blockReason, + evalRes, + scaledCost, + baseCost, + surcharge, + effectiveGold, + pratchettHint, + dupHint, + emeraldHint, + canActThisCard, + }; +} + +function clampPayInt(value, minV, maxV) { + let n = Math.floor(Number(value)); + if (!Number.isFinite(n)) n = 0; + const lo = Math.floor(Number(minV) || 0); + const hiRaw = maxV === '' || maxV === undefined || maxV === null ? null : Number(maxV); + const hi = hiRaw === null || !Number.isFinite(hiRaw) ? null : Math.floor(hiRaw); + n = Math.max(lo, n); + if (hi !== null) n = Math.min(hi, n); + return n; +} + +function readMarketPayRow(row) { + const gEl = row.querySelector('.pay-g'); + const sEl = row.querySelector('.pay-s'); + const mEl = row.querySelector('.pay-m'); + const g = (!gEl || gEl.disabled) ? 0 : clampPayInt(gEl.value, gEl.min, gEl.max); + const s = (!sEl || sEl.disabled) ? 0 : clampPayInt(sEl.value, sEl.min, sEl.max); + const m = (!mEl || mEl.disabled) ? 0 : clampPayInt(mEl.value, mEl.min, mEl.max); + return { gold: g, strength: s, magic: m }; +} + +function mkPayField(label, cls, minV, maxV, value, disabled, title, resourceIconKey) { + const lab = document.createElement('label'); + lab.className = 'market-pay-field'; + if (title) lab.title = title; + const span = document.createElement('span'); + span.className = 'market-pay-field-label'; + if (resourceIconKey && TABLEAU_RESOURCE_ICONS[resourceIconKey]) { + span.classList.add(`market-pay-field-label--${resourceIconKey}`); + const img = document.createElement('img'); + img.className = 'market-pay-label-icon'; + img.src = TABLEAU_RESOURCE_ICONS[resourceIconKey]; + img.alt = ''; + span.appendChild(img); + if (label) span.appendChild(document.createTextNode(` ${label}`)); + } else { + span.textContent = label; + } + lab.appendChild(span); + const inp = document.createElement('input'); + inp.type = 'number'; + inp.className = `market-pay-input ${cls}`; + inp.min = String(minV); + inp.max = maxV === null || maxV === undefined ? '' : String(maxV); + inp.value = String(value); + inp.disabled = !!disabled; + lab.appendChild(inp); + return lab; +} + +function fillMarketCostSummary(costEl, card, ctx, pay) { + const stackTail = ` · stack ×${ctx.stackSize}`; + if (card.citizen_id != null) { + costEl.appendChild(document.createTextNode('Cost: ')); + costEl.appendChild(makeModalResourceInline('gold', ctx.scaledCost, 'modal-gold', false)); + if (ctx.dupHint) costEl.appendChild(document.createTextNode(` (${ctx.dupHint})`)); + if (ctx.emeraldHint) costEl.appendChild(document.createTextNode(` · ${ctx.emeraldHint}`)); + costEl.appendChild(document.createTextNode(' · suggested pay ')); + costEl.appendChild(makeModalResourceInline('gold', pay.payGold ?? 0, 'modal-gold', false)); + if (pay.payMagic) { + costEl.appendChild(document.createTextNode(', ')); + costEl.appendChild(makeModalResourceInline('magic', pay.payMagic ?? 0, 'modal-mag', false)); + } + costEl.appendChild(document.createTextNode(stackTail)); + return; + } + if (card.domain_id != null) { + costEl.appendChild(document.createTextNode('Cost: ')); + costEl.appendChild(makeModalResourceInline('gold', ctx.effectiveGold, 'modal-gold', false)); + if (ctx.pratchettHint) costEl.appendChild(document.createTextNode(` (${ctx.pratchettHint})`)); + costEl.appendChild(document.createTextNode(' · suggested pay ')); + costEl.appendChild(makeModalResourceInline('gold', pay.payGold ?? 0, 'modal-gold', false)); + if (pay.payMagic) { + costEl.appendChild(document.createTextNode(', ')); + costEl.appendChild(makeModalResourceInline('magic', pay.payMagic ?? 0, 'modal-mag', false)); + } + costEl.appendChild(document.createTextNode(stackTail)); + return; + } + if (card.monster_id != null) { + const sc = Number(ctx.top?.strength_cost || 0); + const mm = Number(ctx.top?.magic_cost || 0); + costEl.appendChild(document.createTextNode('Cost: ')); + costEl.appendChild(makeModalResourceInline('strength', sc, 'modal-str', false)); + costEl.appendChild(document.createTextNode(' + ')); + costEl.appendChild(makeModalResourceInline('magic', mm, 'modal-mag', false)); + costEl.appendChild(document.createTextNode(' minimum · suggested pay ')); + costEl.appendChild(makeModalResourceInline('strength', pay.payStrength ?? 0, 'modal-str', false)); + costEl.appendChild(document.createTextNode(', ')); + costEl.appendChild(makeModalResourceInline('magic', pay.payMagic ?? 0, 'modal-mag', false)); + costEl.appendChild(document.createTextNode(stackTail)); + } +} + +function appendMarketActionUI(infoEl, card, ctx) { + const panel = mk('market-action-panel'); + const actName = ctx.actingPlayer?.name || ctx.reqId || 'Active player'; + + const hdr = mk('market-action-heading'); + if (ctx.standardActionPhase) { + hdr.textContent = ctx.isYourTurn + ? `Your action (${ctx.actionsRemaining} remaining)` + : `${actName}'s turn — ${ctx.actionsRemaining} action(s) remaining`; + } else { + hdr.textContent = `Phase: ${fmtPhase(ctx.phase)}`; + } + panel.appendChild(hdr); + + if (ctx.actingPlayer) { + const p = ctx.actingPlayer; + const resRow = mk('market-resources-row market-resources-row--strip'); + const intro = document.createElement('span'); + intro.className = 'market-resources-intro'; + intro.textContent = `Resources (${actName}):`; + resRow.appendChild(intro); + resRow.appendChild(makeResourceScorePill('gold', p.gold_score, 'Gold', TABLEAU_RESOURCE_ICONS.gold)); + resRow.appendChild(makeResourceScorePill('strength', p.strength_score, 'Strength', TABLEAU_RESOURCE_ICONS.strength)); + resRow.appendChild(makeResourceScorePill('magic', p.magic_score, 'Magic', TABLEAU_RESOURCE_ICONS.magic)); + resRow.appendChild(makeVpScorePill(p.victory_score)); + panel.appendChild(resRow); + } + + const fx = []; + if (ctx.emeraldActive) fx.push('Emerald Stronghold: ignore citizen duplicate surcharge'); + if (ctx.pratchettActive) fx.push("Pratchett's Plateau: domains cost 1 less gold"); + if (fx.length) { + const fb = mk('market-effects-banner'); + fb.textContent = `Active: ${fx.join(' · ')}`; + panel.appendChild(fb); + } + + if (ctx.blockReason) { + const br = mk('market-block-note'); + br.textContent = ctx.blockReason; + panel.appendChild(br); + } + + const payWrap = mk('market-pay-row'); + const Gmax = Number(ctx.actingPlayer?.gold_score || 0); + const Smax = Number(ctx.actingPlayer?.strength_score || 0); + const Mmax = Number(ctx.actingPlayer?.magic_score || 0); + const pay = ctx.evalRes; + const inputsDisabled = !ctx.standardActionPhase; + + let primaryLabel = ''; + + if (card.citizen_id != null) { + primaryLabel = 'Hire citizen'; + payWrap.dataset.citizenId = String(card.citizen_id); + payWrap.appendChild(mkPayField('', 'pay-g', 0, Gmax, pay.payGold ?? 0, inputsDisabled, 'Gold payment', 'gold')); + payWrap.appendChild(mkPayField('', 'pay-s', 0, 0, 0, true, 'Citizens use gold and magic', 'strength')); + payWrap.appendChild(mkPayField('', 'pay-m', 0, Mmax, pay.payMagic ?? 0, inputsDisabled, 'Magic payment', 'magic')); + } else if (card.domain_id != null) { + primaryLabel = 'Build domain'; + payWrap.dataset.domainId = String(card.domain_id); + payWrap.appendChild(mkPayField('', 'pay-g', 0, Gmax, pay.payGold ?? 0, inputsDisabled, 'Gold payment', 'gold')); + payWrap.appendChild(mkPayField('', 'pay-s', 0, 0, 0, true, 'Domains use gold and magic', 'strength')); + payWrap.appendChild(mkPayField('', 'pay-m', 0, Mmax, pay.payMagic ?? 0, inputsDisabled, 'Magic payment', 'magic')); + } else if (card.monster_id != null) { + primaryLabel = 'Slay monster'; + payWrap.dataset.monsterId = String(card.monster_id); + payWrap.appendChild(mkPayField('', 'pay-g', 0, 0, 0, true, 'Monsters use strength and magic', 'gold')); + payWrap.appendChild(mkPayField('', 'pay-s', 0, Smax, pay.payStrength ?? 0, inputsDisabled, 'Strength payment', 'strength')); + payWrap.appendChild(mkPayField('', 'pay-m', 0, Mmax, pay.payMagic ?? 0, inputsDisabled, 'Magic payment', 'magic')); + } + + const costEl = mk('market-cost-summary'); + fillMarketCostSummary(costEl, card, ctx, pay); + panel.appendChild(costEl); + + const affordEl = mk(ctx.evalRes.ok ? 'market-afford-ok' : 'market-afford-bad'); + affordEl.textContent = ctx.evalRes.ok + ? 'Suggested payment fits current resources.' + : 'Suggested payment exceeds resources — adjust G/S/M or magic coverage.'; + panel.appendChild(affordEl); + + const fieldsRow = mk('market-pay-fields'); + fieldsRow.appendChild(payWrap); + panel.appendChild(fieldsRow); + + const btnRow = mk('market-primary-actions'); + + function attachPrimary(btnEl, disabled) { + if (disabled) btnEl.disabled = true; + btnRow.appendChild(btnEl); + } + + const hireDisabled = !(card.citizen_id != null && ctx.canActThisCard); + const buildDisabled = !(card.domain_id != null && ctx.canActThisCard); + const slayDisabled = !(card.monster_id != null && ctx.canActThisCard); + + if (card.citizen_id != null) { + attachPrimary(promptButton('Hire', () => { + const p = readMarketPayRow(payWrap); + postGameAction({ + player_id: PLAYER_ID, + action_type: 'hire_citizen', + citizen_id: Number(card.citizen_id), + payment: { gold: p.gold, strength: p.strength, magic: p.magic }, + }); + document.getElementById('card-modal-overlay')?.remove(); + }), hireDisabled); + } else if (card.domain_id != null) { + attachPrimary(promptButton('Build', () => { + const p = readMarketPayRow(payWrap); + postGameAction({ + player_id: PLAYER_ID, + action_type: 'build_domain', + domain_id: Number(card.domain_id), + payment: { gold: p.gold, strength: p.strength, magic: p.magic }, + }); + document.getElementById('card-modal-overlay')?.remove(); + }), buildDisabled); + } else if (card.monster_id != null) { + attachPrimary(promptButton('Slay', () => { + const p = readMarketPayRow(payWrap); + postGameAction({ + player_id: PLAYER_ID, + action_type: 'slay_monster', + monster_id: Number(card.monster_id), + payment: { gold: p.gold, strength: p.strength, magic: p.magic }, + }); + document.getElementById('card-modal-overlay')?.remove(); + }), slayDisabled); + } + + panel.appendChild(btnRow); + + const help = mk('market-action-help'); + help.textContent = + primaryLabel + ? `${primaryLabel}: adjust gold, strength, and magic payment (magic covers shortages after you spend required minimum magic on monsters).` + : ''; + if (help.textContent) panel.appendChild(help); + + infoEl.appendChild(panel); +} + +function openMarketCardModal(card) { + if (document.getElementById('game-prompt-overlay')) return; + if (document.getElementById('card-modal-overlay')) return; + + const state = latestGameState; + const ctx = evaluateMarketCardContext(card, state); + + const overlay = document.createElement('div'); + overlay.id = 'card-modal-overlay'; + overlay.className = 'card-modal-overlay'; + + const modal = mk('card-modal card-modal--market'); + modal.addEventListener('click', e => e.stopPropagation()); + + const url = cardImageUrl(card); + if (url) { + const img = document.createElement('img'); + img.className = 'card-modal-img'; + img.src = url; + modal.appendChild(img); + } + + const info = mk('card-modal-info'); + + const heading = document.createElement('h2'); + heading.className = 'modal-card-name'; + heading.textContent = card.name || '?'; + info.appendChild(heading); + + appendCardModalStatRows(info, card); + + const rc = citizenRoleCounts(card); + const rp = []; + if (rc.sn > 0) rp.push(`Shadow +${rc.sn}`); + if (rc.hn > 0) rp.push(`Holy +${rc.hn}`); + if (rc.son > 0) rp.push(`Soldier +${rc.son}`); + if (rc.wn > 0) rp.push(`Worker +${rc.wn}`); + if (rp.length && (card.citizen_id != null || card.domain_id != null)) { + const row = mk('modal-stat-row'); + const l = document.createElement('span'); + l.className = 'modal-stat-label'; + l.textContent = 'Roles'; + const v = document.createElement('span'); + v.className = 'modal-stat-value'; + v.textContent = rp.join(' · '); + row.appendChild(l); + row.appendChild(v); + info.appendChild(row); + } + + if (card.text) { + const t = document.createElement('p'); + t.className = 'modal-card-text'; + t.textContent = card.text; + info.appendChild(t); + } + + const rules = cardDetailedRules(card); + if (rules && rules !== (card.text || '').toString().trim()) { + const t2 = document.createElement('p'); + t2.className = 'modal-card-text market-rules-extra'; + t2.textContent = rules; + info.appendChild(t2); + } + + appendMarketActionUI(info, card, ctx); + + modal.appendChild(info); + overlay.appendChild(modal); + overlay.addEventListener('click', () => overlay.remove()); + document.addEventListener('keydown', function esc(e) { + if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', esc); } + }); + document.body.appendChild(overlay); +} + +function isBoardMarketCard(card, cardEl) { + if (!cardEl || !cardEl.closest('.center-board')) return false; + return card.monster_id != null || card.citizen_id != null || card.domain_id != null; +} + +// ── Card click modal ────────────────────────────────────────────────────── +document.addEventListener('click', e => { + const cardEl = e.target.closest('.card[data-card]'); + if (!cardEl) return; + _previewEl.style.display = 'none'; + const card = JSON.parse(cardEl.dataset.card); + if (isBoardMarketCard(card, cardEl)) { + openMarketCardModal(card); + return; + } + openCardModal(card); +}); + +function openCardModal(card) { + if (document.getElementById('game-prompt-overlay')) return; + if (document.getElementById('card-modal-overlay')) return; + + const overlay = document.createElement('div'); + overlay.id = 'card-modal-overlay'; + overlay.className = 'card-modal-overlay'; + + const modal = mk('card-modal'); + modal.addEventListener('click', e => e.stopPropagation()); + + const url = cardImageUrl(card); + if (url) { + const img = document.createElement('img'); + img.className = 'card-modal-img'; + img.src = url; + modal.appendChild(img); + } + + const info = mk('card-modal-info'); + + const heading = document.createElement('h2'); + heading.className = 'modal-card-name'; + heading.textContent = card.name || '?'; + info.appendChild(heading); + + appendCardModalStatRows(info, card); + + if (card.text) { + const t = document.createElement('p'); + t.className = 'modal-card-text'; + t.textContent = card.text; + info.appendChild(t); + } + + modal.appendChild(info); + overlay.appendChild(modal); + overlay.addEventListener('click', () => overlay.remove()); + document.addEventListener('keydown', function esc(e) { + if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', esc); } + }); + document.body.appendChild(overlay); +} + +function buildCardStats(card) { + const rows = []; + const push = (label, value, cls, resource, leadingPlus) => { + if (value != null && value !== 0 && value !== '') { + rows.push({ + label, + value, + cls: cls || '', + resource: resource || null, + leadingPlus: !!leadingPlus, + }); + } + }; + + if (card.monster_id != null) push('Type', 'Monster', null, null, false); + else if (card.citizen_id != null) push('Type', 'Citizen', null, null, false); + else if (card.domain_id != null) push('Type', 'Domain', null, null, false); + else if (card.duke_id != null) push('Type', 'Duke', null, null, false); + else if (card.starter_id != null) push('Type', 'Starter', null, null, false); + + if (card.gold_cost) push('Gold cost', card.gold_cost, 'modal-gold', 'gold', false); + if (card.strength_cost) push('Str cost', card.strength_cost, 'modal-str', 'strength', false); + if (card.magic_cost) push('Mag cost', card.magic_cost, 'modal-mag', 'magic', false); + if (card.vp_reward) push('VP reward', card.vp_reward, 'modal-vp', 'vp', false); + if (card.gold_reward) push('Gold reward', card.gold_reward, 'modal-gold', 'gold', true); + if (card.strength_reward) push('Str reward', card.strength_reward, 'modal-str', 'strength', true); + if (card.magic_reward) push('Mag reward', card.magic_reward, 'modal-mag', 'magic', true); + + if (card.domain_id != null) { + const req = []; + if (card.shadow_count) req.push(`${card.shadow_count} Shadow`); + if (card.holy_count) req.push(`${card.holy_count} Holy`); + if (card.soldier_count) req.push(`${card.soldier_count} Soldier`); + if (card.worker_count) req.push(`${card.worker_count} Worker`); + if (req.length) push('Requires', req.join(', ')); + } + + if (card.starter_id != null) { + const m1 = card.roll_match1, m2 = card.roll_match2; + if (m1 && m2 && m1 !== m2) push('Rolls', `${m1}, ${m2}`); + else if (m1) push('Roll', String(m1)); + } + + if (card.is_flipped) push('Status', 'Flipped'); + + return rows; +} + +// ── Prompt modal (required choices, concurrent setup) ───────────────────── +function clampDie(n) { + const x = Number(n); + if (!Number.isFinite(x)) return 1; + return Math.max(1, Math.min(6, Math.trunc(x))); +} + +function syncConcurrentPolling(state) { + const ca = state?.concurrent_action; + const pend = ca && Array.isArray(ca.pending) ? ca.pending : []; + const should = pend.length > 0; + if (should && !concurrentPollTimer) { + concurrentPollTimer = setInterval(() => { + fetchGameStateFromApi(); + }, 1500); + } else if (!should && concurrentPollTimer) { + clearInterval(concurrentPollTimer); + concurrentPollTimer = null; + } +} + +async function fetchGameStateFromApi() { + if (!GAME_ID || !PLAYER_ID) return; + try { + const res = await fetch(`/api/game/${encodeURIComponent(GAME_ID)}/state?player_id=${encodeURIComponent(PLAYER_ID)}`); + if (!res.ok) return; + const data = await res.json(); + render(data); + } catch (e) { + console.error(e); + } +} + +async function postGameAction(body) { + const res = await fetch(`/api/game/${encodeURIComponent(GAME_ID)}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const payload = await res.json().catch(() => ({})); + if (!res.ok) { + const detail = payload?.detail || res.statusText || 'Request failed'; + window.alert(detail); + return false; + } + if (payload?.game_state) render(payload.game_state); + else fetchGameStateFromApi(); + return true; +} + +function removePromptOverlay() { + const el = document.getElementById('game-prompt-overlay'); + if (el && el._promptEscHandler) { + document.removeEventListener('keydown', el._promptEscHandler); + el._promptEscHandler = null; + } + el?.remove(); +} + +function openPromptOverlayShell(opts) { + removePromptOverlay(); + const { title, subtitle, dismissible, bodyEl, footerEl } = opts; + + const overlay = document.createElement('div'); + overlay.id = 'game-prompt-overlay'; + overlay.className = 'card-modal-overlay game-prompt-overlay'; + + const modal = mk('card-modal card-modal--prompt'); + modal.addEventListener('click', e => e.stopPropagation()); + + const head = mk('prompt-modal-head'); + const h = document.createElement('h2'); + h.className = 'modal-card-name prompt-modal-title'; + h.textContent = title; + head.appendChild(h); + if (subtitle) { + const sub = mk('prompt-modal-subtitle'); + sub.textContent = subtitle; + head.appendChild(sub); + } + modal.appendChild(head); + + if (bodyEl) modal.appendChild(bodyEl); + + if (footerEl) { + const ft = mk('prompt-modal-footer'); + ft.appendChild(footerEl); + modal.appendChild(ft); + } + + overlay.appendChild(modal); + + function dismiss() { + removePromptOverlay(); + } + + function onKey(e) { + if (e.key === 'Escape' && dismissible) dismiss(); + } + + if (dismissible) { + overlay.addEventListener('click', dismiss); + overlay._promptEscHandler = onKey; + document.addEventListener('keydown', onKey); + } + + document.body.appendChild(overlay); +} + +function promptButton(label, onClick, secondary) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = secondary ? 'prompt-btn prompt-btn-secondary' : 'prompt-btn'; + b.textContent = label; + b.addEventListener('click', onClick); + return b; +} + +function promptActionsRow(buttons) { + const row = mk('prompt-modal-actions'); + buttons.forEach(b => row.appendChild(b)); + return row; +} + +function harvestTurnChip(state, forPlayerId) { + const pid = (forPlayerId || '').toString(); + const ap = state?.active_player_id; + if (!pid || ap == null) return null; + const onTurn = idsMatch(pid, ap); + const el = mk('prompt-turn-chip'); + el.textContent = onTurn ? 'On-turn harvest' : 'Off-turn harvest'; + if (onTurn) el.classList.add('is-on-turn'); + return el; +} + +function playerById(state, pid) { + const list = state?.player_list || []; + return list.find(p => idsMatch(p.player_id, pid)) || null; +} + +function pendingPlayerLabels(state, pending) { + const players = state?.player_list || []; + return (pending || []).map(pid => { + const p = players.find(x => idsMatch(x.player_id, pid)); + return p?.name ? p.name : pid; + }); +} + +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 (!GAME_ID || !PLAYER_ID || finalizeRollInFlight) return; + finalizeRollInFlight = true; + try { + await postGameAction({ + player_id: PLAYER_ID, + action_type: 'finalize_roll', + die_one: clampDie(d1), + die_two: clampDie(d2), + }); + } finally { + finalizeRollInFlight = false; + } +} + +/** Affordable roll.set_one_die choices for the player who must finalize (may be empty). */ +function finalizeRollModifierOptions(state) { + const req = state?.action_required || {}; + if ((req.action || '').toString() !== 'finalize_roll') return []; + const reqId = (req.id || '').toString(); + const actingPlayer = playerById(state, reqId); + if (!actingPlayer) return []; + const rolled1 = clampDie(state?.rolled_die_one ?? state?.die_one ?? 1); + const rolled2 = clampDie(state?.rolled_die_two ?? state?.die_two ?? 1); + return listRollSetOneDieOptions(actingPlayer, rolled1, rolled2, state.turn_number); +} + +/** No prompt when there are zero modifiers — finalize immediately (matches dev-client behavior). */ +function maybeAutoFinalizeRoll(state) { + if (!GAME_ID || !PLAYER_ID || finalizeRollInFlight) return; + if ((state?.phase || '').toString() !== 'roll_pending') return; + const req = state?.action_required || {}; + if ((req.action || '').toString() !== 'finalize_roll') return; + if (!idsMatch(req.id, PLAYER_ID)) return; + if (finalizeRollModifierOptions(state).length > 0) return; + const rolled1 = clampDie(state?.rolled_die_one ?? state?.die_one ?? 1); + const rolled2 = clampDie(state?.rolled_die_two ?? state?.die_two ?? 1); + sendFinalizeRollChoice(rolled1, rolled2); +} + +function labelForChoiceToken(tok) { + const t = (tok || '').toString().trim().toLowerCase(); + if (t === 'g') return 'Gold'; + 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; +} + +function parseChooseCommand(cmd) { + const parts = (cmd || '').toString().trim().split(/\s+/); + if (!parts.length || parts[0] !== 'choose') return []; + const options = []; + for (let i = 1; i + 1 < parts.length; i += 2) { + const token = parts[i]; + const amount = parts[i + 1]; + const tl = (token || '').toString().trim().toLowerCase(); + if (!(tl === 'g' || tl === 's' || tl === 'm' || tl === 'v' || tl.startsWith('citizens.'))) continue; + options.push({ token, amount }); + if (options.length >= 3) break; + } + return options; +} + +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 dukePromptBlurb(card) { + if (!card || typeof card !== 'object') return ''; + const rawText = (card.text ?? '').toString().trim(); + if (rawText) return rawText; + const passive = (card.passive_effect ?? '').toString().trim(); + const activation = (card.activation_effect ?? '').toString().trim(); + const bits = []; + if (passive) bits.push(`Passive: ${passive}`); + if (activation) bits.push(`Activation: ${activation}`); + return bits.join('\n'); +} + +/** Matches dev-client cardFullText duke multiplier display (resources use ×1/N). */ +function dukeScalingLine(card) { + if (!card || typeof card !== 'object') return ''; + if (card.duke_id == null) return ''; + const mults = []; + const add = (label, val) => { + if (val === undefined || val === null) return; + const n = Number(val); + if (!Number.isFinite(n) || n === 0) return; + mults.push(`${label}×${n}`); + }; + const addResource = (label, val) => { + if (val === undefined || val === null) return; + const n = Number(val); + if (!Number.isFinite(n) || n === 0) return; + mults.push(`${label}×1/${n}`); + }; + addResource('Gold', card.gold_multiplier); + addResource('Strength', card.strength_multiplier); + addResource('Magic', card.magic_multiplier); + add('Shadow', card.shadow_multiplier); + add('Holy', card.holy_multiplier); + add('Soldier', card.soldier_multiplier); + add('Worker', card.worker_multiplier); + add('Monster', card.monster_multiplier); + add('Citizen', card.citizen_multiplier); + add('Domain', card.domain_multiplier); + add('Boss', card.boss_multiplier); + add('Minion', card.minion_multiplier); + add('Beast', card.beast_multiplier); + add('Titan', card.titan_multiplier); + return mults.join(' · '); +} + +function renderConcurrentChooseDuke(state, concurrent) { + const pending = Array.isArray(concurrent.pending) ? concurrent.pending : []; + const completed = Array.isArray(concurrent.completed) ? concurrent.completed : []; + const isPending = !!(PLAYER_ID && pending.some(pid => idsMatch(pid, PLAYER_ID))); + const totalParticipants = pending.length + completed.length; + + const players = state?.player_list || []; + const you = players.find(p => idsMatch(p.player_id, PLAYER_ID)) || null; + const waitingLabels = pendingPlayerLabels(state, pending); + + const body = mk('prompt-modal-body'); + + const status = mk('prompt-modal-note'); + status.textContent = + `Starting setup: ${completed.length}/${totalParticipants} duke choice(s) submitted.` + + (pending.length ? ` Waiting on: ${waitingLabels.join(', ')}.` : ''); + body.appendChild(status); + + if (!isPending) { + const youDone = !!(PLAYER_ID && completed.some(pid => idsMatch(pid, PLAYER_ID))); + const line = mk('prompt-modal-note'); + line.textContent = youDone + ? 'You have already chosen your duke. Waiting on the other player(s).' + : 'Starting setup is in progress.'; + body.appendChild(line); + openPromptOverlayShell({ + title: 'Choose your Duke', + subtitle: null, + dismissible: true, + bodyEl: body, + footerEl: null, + }); + return; + } + + const dukes = Array.isArray(you?.owned_dukes) ? you.owned_dukes : []; + if (!dukes.length) { + body.appendChild(document.createTextNode('No dukes found to choose from.')); + openPromptOverlayShell({ + title: 'Choose your Duke', + dismissible: false, + bodyEl: body, + footerEl: null, + }); + return; + } + + const list = mk('prompt-choice-list'); + dukes.forEach(d => { + const id = d?.duke_id; + const name = d?.name || `Duke #${id}`; + const cardEl = mk('prompt-choice-card'); + + const inner = mk('prompt-choice-card-inner'); + const url = cardImageUrl(d); + if (url) { + const wrap = mk('prompt-choice-card-img-wrap'); + const img = document.createElement('img'); + img.className = 'prompt-choice-card-img'; + img.alt = ''; + img.loading = 'lazy'; + img.src = url; + img.onerror = () => wrap.remove(); + wrap.appendChild(img); + inner.appendChild(wrap); + } + + const main = mk('prompt-choice-card-main'); + const nm = mk('prompt-choice-card-title'); + nm.textContent = `${name} (#${id})`; + main.appendChild(nm); + const scalingLine = dukeScalingLine(d); + if (scalingLine) { + const sc = mk('prompt-choice-card-scaling'); + sc.textContent = scalingLine; + main.appendChild(sc); + } + const blurb = dukePromptBlurb(d); + if (blurb) { + const tx = mk('prompt-choice-card-text'); + tx.textContent = blurb; + main.appendChild(tx); + } + const row = mk('prompt-choice-card-actions'); + row.appendChild(promptButton('Keep this duke', () => { + postGameAction({ + player_id: PLAYER_ID, + action_type: 'submit_concurrent_action', + kind: 'choose_duke', + response: String(id), + }); + })); + main.appendChild(row); + inner.appendChild(main); + cardEl.appendChild(inner); + list.appendChild(cardEl); + }); + body.appendChild(list); + + openPromptOverlayShell({ + title: 'Choose 1 Duke to keep', + subtitle: null, + dismissible: false, + bodyEl: body, + footerEl: null, + }); +} + +function renderConcurrentFlipCitizen(state, concurrent) { + const pending = Array.isArray(concurrent.pending) ? concurrent.pending : []; + const completed = Array.isArray(concurrent.completed) ? concurrent.completed : []; + const isPending = !!(PLAYER_ID && pending.some(pid => idsMatch(pid, PLAYER_ID))); + const totalParticipants = pending.length + completed.length; + const data = concurrent.data || {}; + const buyerId = (data.buyer_id || '').toString(); + + const buyer = playerById(state, buyerId); + const buyerTag = buyer?.name || buyerId || ''; + const waitingLabels = pendingPlayerLabels(state, pending); + + const body = mk('prompt-modal-body'); + + const status = mk('prompt-modal-note'); + status.textContent = + `Cursed Cavern — flip one citizen face-down: ${completed.length}/${totalParticipants} player choice(s) submitted.` + + (pending.length ? ` Waiting on: ${waitingLabels.join(', ')}.` : '') + + (buyerTag ? ` Triggered by ${buyerTag}.` : ''); + body.appendChild(status); + + if (!isPending) { + const youDone = !!(PLAYER_ID && completed.some(pid => idsMatch(pid, PLAYER_ID))); + const line = mk('prompt-modal-note'); + line.textContent = 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).'; + body.appendChild(line); + openPromptOverlayShell({ + title: 'Flip a citizen', + dismissible: true, + bodyEl: body, + footerEl: null, + }); + return; + } + + const you = playerById(state, PLAYER_ID); + const citizens = Array.isArray(you?.owned_citizens) ? you.owned_citizens : []; + const choices = []; + citizens.forEach((c, idx) => { + if (!c || c.is_flipped) return; + choices.push({ idx, card: c, nm: (c.name || `Citizen #${idx}`).toString() }); + }); + + if (!choices.length) { + body.appendChild(document.createTextNode('No face-up citizens on your tableau — contact host if this seems wrong.')); + openPromptOverlayShell({ + title: 'Flip a citizen', + dismissible: false, + bodyEl: body, + footerEl: null, + }); + return; + } + + const list = mk('prompt-choice-list'); + choices.forEach(({ idx, card, nm }) => { + const cardEl = mk('prompt-choice-card'); + const titleEl = mk('prompt-choice-card-title'); + titleEl.textContent = `${nm} (slot #${idx})`; + cardEl.appendChild(titleEl); + const metaParts = []; + if (card.roll_match1 !== undefined || card.roll_match2 !== undefined) { + metaParts.push(`Roll ${card.roll_match1 ?? ''}/${card.roll_match2 ?? ''}`); + } + if (card.gold_cost !== undefined) metaParts.push(`${card.gold_cost}g`); + if (metaParts.length) { + const meta = mk('prompt-choice-card-meta'); + meta.textContent = metaParts.join(' · '); + cardEl.appendChild(meta); + } + const row = mk('prompt-choice-card-actions'); + row.appendChild(promptButton('Flip this citizen face-down', () => { + postGameAction({ + player_id: PLAYER_ID, + action_type: 'submit_concurrent_action', + kind: 'flip_one_citizen', + response: String(idx), + }); + })); + cardEl.appendChild(row); + list.appendChild(cardEl); + }); + body.appendChild(list); + + openPromptOverlayShell({ + title: 'Choose 1 citizen to flip face-down', + dismissible: false, + bodyEl: body, + footerEl: null, + }); +} + +function renderConcurrentPanel(state, concurrent) { + const kind = concurrent?.kind || ''; + if (kind === 'choose_duke') return renderConcurrentChooseDuke(state, concurrent); + if (kind === 'flip_one_citizen') return renderConcurrentFlipCitizen(state, concurrent); + + const pending = Array.isArray(concurrent.pending) ? concurrent.pending : []; + const body = mk('prompt-modal-body'); + const note = mk('prompt-modal-note'); + note.textContent = + `Waiting on concurrent action "${kind}" (${pending.length} player(s) still need to respond).`; + body.appendChild(note); + openPromptOverlayShell({ + title: 'Waiting', + dismissible: true, + bodyEl: body, + footerEl: null, + }); +} + +function renderFinalizeRollPrompt(state) { + const req = state?.action_required || {}; + const reqId = (req?.id || '').toString(); + const isYou = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID)); + const rolled1 = clampDie(state?.rolled_die_one ?? state?.die_one ?? 1); + const rolled2 = clampDie(state?.rolled_die_two ?? state?.die_two ?? 1); + + const body = mk('prompt-modal-body'); + + if (!isYou) { + const note = mk('prompt-modal-note'); + note.textContent = `Waiting on ${reqId} to finalize the roll.`; + body.appendChild(note); + openPromptOverlayShell({ + title: 'Finalize roll', + dismissible: true, + bodyEl: body, + footerEl: null, + }); + return; + } + + const you = playerById(state, PLAYER_ID); + const options = listRollSetOneDieOptions(you, rolled1, rolled2, state.turn_number); + + const diceLine = mk('prompt-modal-dice-line'); + diceLine.appendChild(makeDie(rolled1)); + diceLine.appendChild(document.createTextNode(' + ')); + diceLine.appendChild(makeDie(rolled2)); + diceLine.appendChild(document.createTextNode(` = ${rolled1 + rolled2}`)); + body.appendChild(diceLine); + + const foot = mk('prompt-modal-actions prompt-modal-actions--wrap'); + foot.appendChild(promptButton(`Keep ${rolled1} + ${rolled2}`, () => sendFinalizeRollChoice(rolled1, rolled2))); + options.forEach(o => { + const fromVal = o.die === 1 ? rolled1 : rolled2; + const d1 = o.die === 1 ? o.target : rolled1; + const d2 = o.die === 2 ? o.target : rolled2; + foot.appendChild(promptButton( + `Die ${o.die}: ${fromVal} → ${o.target} (${o.costGold}g · ${o.domainName})`, + () => sendFinalizeRollChoice(d1, d2), + )); + }); + + const hint = mk('prompt-modal-note'); + hint.textContent = 'Choose a roll modifier or keep the rolled dice.'; + body.appendChild(hint); + + openPromptOverlayShell({ + title: 'Finalize roll', + subtitle: null, + dismissible: false, + bodyEl: body, + footerEl: foot, + }); +} + +function renderDomainSelfConvertPrompt(state) { + const req = state?.action_required || {}; + const reqId = (req?.id || '').toString(); + const isYou = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID)); + const prc = state?.pending_required_choice || null; + const dn = (prc?.domain_name || 'Domain').toString(); + const kv = prc?.kv || {}; + const explain = selfConvertExplain(kv); + + const body = mk('prompt-modal-body'); + if (!isYou) { + const note = mk('prompt-modal-note'); + note.textContent = `Waiting on ${reqId} — ${dn} optional trade.`; + body.appendChild(note); + openPromptOverlayShell({ + title: `${dn}: trade`, + dismissible: true, + bodyEl: body, + footerEl: null, + }); + return; + } + + const sub = mk('prompt-modal-note'); + sub.textContent = explain; + body.appendChild(sub); + + const foot = promptActionsRow([ + promptButton('Confirm trade', () => postGameAction({ + player_id: PLAYER_ID, + action_type: 'act_on_required_action', + action: 'confirm_self_convert', + })), + promptButton('Decline', () => postGameAction({ + player_id: PLAYER_ID, + action_type: 'act_on_required_action', + action: 'skip', + }), true), + ]); + + openPromptOverlayShell({ + title: `${dn}: optional trade`, + dismissible: false, + bodyEl: body, + footerEl: foot, + }); +} + +function harvestExchangeExplain(command) { + const parts = (command || '').trim().split(/\s+/); + if (parts.length < 5 || parts[0].toLowerCase() !== 'exchange') return (command || '').trim() || 'Optional harvest exchange.'; + const pay = parts[1].toLowerCase(); + const payN = parts[2]; + const gain = parts[3].toLowerCase(); + const gainN = parts[4]; + const labels = { g: 'gold', s: 'strength', m: 'magic', v: 'victory points' }; + return `Pay ${payN} ${labels[pay] || pay}, gain ${gainN} ${labels[gain] || gain}.`; +} + +function renderHarvestOptionalExchangePrompt(state) { + const req = state?.action_required || {}; + const reqId = (req?.id || '').toString(); + const isYou = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID)); + const prc = state?.pending_required_choice || null; + const cmd = (prc?.command || '').toString(); + const explain = harvestExchangeExplain(cmd); + + const body = mk('prompt-modal-body'); + if (!isYou) { + const note = mk('prompt-modal-note'); + note.textContent = `Waiting on ${reqId} — optional citizen harvest exchange.`; + body.appendChild(note); + openPromptOverlayShell({ + title: 'Harvest exchange', + dismissible: true, + bodyEl: body, + footerEl: null, + }); + return; + } + + const sub = mk('prompt-modal-note'); + sub.textContent = explain; + body.appendChild(sub); + + const foot = promptActionsRow([ + promptButton('Take exchange', () => postGameAction({ + player_id: PLAYER_ID, + action_type: 'act_on_required_action', + action: 'confirm_harvest_exchange', + })), + promptButton('Skip (keep resources)', () => postGameAction({ + player_id: PLAYER_ID, + action_type: 'act_on_required_action', + action: 'skip_harvest_exchange', + }), true), + ]); + + openPromptOverlayShell({ + title: 'Harvest: optional exchange', + dismissible: false, + bodyEl: body, + footerEl: foot, + }); +} + +function renderDomainChoosePlayer(state) { + const req = state?.action_required || {}; + const reqId = (req?.id || '').toString(); + const isYou = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID)); + const prc = state?.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.'; + + const body = mk('prompt-modal-body'); + if (!isYou) { + const note = mk('prompt-modal-note'); + note.textContent = `Waiting on ${reqId} to choose a player for ${dn}.`; + body.appendChild(note); + openPromptOverlayShell({ + title: `${dn}`, + dismissible: true, + bodyEl: body, + footerEl: null, + }); + return; + } + + const sub = mk('prompt-modal-note'); + sub.textContent = explain; + body.appendChild(sub); + + const foot = mk('prompt-modal-actions prompt-modal-actions--wrap'); + opts.forEach((o, idx) => { + const nm = (o?.name || o?.player_id || '?').toString(); + foot.appendChild(promptButton(nm, () => postGameAction({ + player_id: PLAYER_ID, + action_type: 'act_on_required_action', + action: `choose_player ${idx + 1}`, + }))); + }); + + const kv = prc?.item?.kv || {}; + const skipLabel = prc?.allow_skip && domainEffectGainIsVp(kv) + ? 'Decline (no pay, no VP)' + : 'Skip (optional)'; + if (prc?.allow_skip) { + foot.appendChild(promptButton(skipLabel, () => postGameAction({ + player_id: PLAYER_ID, + action_type: 'act_on_required_action', + action: 'skip', + }), true)); + } + + openPromptOverlayShell({ + title: `${dn}: choose another player`, + dismissible: false, + bodyEl: body, + footerEl: foot, + }); +} + +function renderDomainChooseMonster(state) { + const req = state?.action_required || {}; + const reqId = (req?.id || '').toString(); + const isYou = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID)); + const prc = state?.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; + + const body = mk('prompt-modal-body'); + if (!isYou) { + const note = mk('prompt-modal-note'); + note.textContent = `Waiting on ${reqId} — ${dn} (monster +${delta} strength cost).`; + body.appendChild(note); + openPromptOverlayShell({ + title: dn, + dismissible: true, + bodyEl: body, + footerEl: null, + }); + return; + } + + const foot = mk('prompt-modal-actions prompt-modal-actions--wrap'); + opts.forEach((o, idx) => { + const nm = (o?.name || '?').toString(); + foot.appendChild(promptButton(nm, () => postGameAction({ + player_id: PLAYER_ID, + action_type: 'act_on_required_action', + action: `choose_monster ${idx + 1}`, + }))); + }); + + openPromptOverlayShell({ + title: `${dn}: strengthen a center monster`, + subtitle: `Add +${delta} to strength cost`, + dismissible: false, + bodyEl: body, + footerEl: foot, + }); +} + +function chooseOptionButtonLabel(opt, idx) { + const token = (opt?.token || '').toString(); + const label = labelForChoiceToken(token); + const amt = Number(opt.amount); + const prettyAmt = Number.isFinite(amt) ? amt : opt.amount; + const tl = token.trim().toLowerCase(); + if (tl === '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 `+(${mText} × ${area}) ${rLabel}`; + } + if (tl.startsWith('citizens.')) { + 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 `Gain ${prettyAmt} ${who}${extraSuffix}`; + } + return `+${prettyAmt} ${label}`; +} + +function renderChoosePrompt(state, chooseCmd) { + const req = state?.action_required || {}; + const reqId = (req?.id || '').toString(); + const isYou = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID)); + const pendingChoice = state?.pending_required_choice || null; + + let options = parseChooseCommand(chooseCmd); + if ( + pendingChoice && + pendingChoice.kind === 'special_payout_choose' && + Array.isArray(pendingChoice.options) && + pendingChoice.options.length + ) { + options = pendingChoice.options; + } + + const body = mk('prompt-modal-body'); + if (!options.length || !isYou) { + const note = mk('prompt-modal-note'); + note.textContent = !options.length + ? `Waiting on required choice: ${chooseCmd}` + : `Waiting on ${reqId} — ${chooseCmd}`; + body.appendChild(note); + openPromptOverlayShell({ + title: 'Choose one', + dismissible: !isYou || !options.length, + bodyEl: body, + footerEl: null, + }); + return; + } + + const foot = mk('prompt-modal-actions prompt-modal-actions--wrap'); + options.forEach((opt, idx) => { + foot.appendChild(promptButton(chooseOptionButtonLabel(opt, idx), () => postGameAction({ + player_id: PLAYER_ID, + action_type: 'act_on_required_action', + action: `choose ${idx + 1}`, + }))); + }); + + openPromptOverlayShell({ + title: 'Choose one', + dismissible: false, + bodyEl: body, + footerEl: foot, + }); +} + +function renderManualHarvestPrompt(state) { + const req = state?.action_required || {}; + const reqId = (req?.id || '').toString(); + const slots = Array.isArray(state?.harvest_prompt_slots) ? state.harvest_prompt_slots : []; + const isYou = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID)); + + const chip = harvestTurnChip(state, reqId); + + const body = mk('prompt-modal-body'); + const headRow = mk('prompt-modal-inline'); + const ht = mk('prompt-modal-note'); + ht.textContent = isYou ? 'Harvest — choose order' : `Harvest in progress for ${reqId}`; + headRow.appendChild(ht); + if (chip) headRow.appendChild(chip); + body.appendChild(headRow); + + if (!isYou || !slots.length) { + const note = mk('prompt-modal-note'); + note.textContent = !isYou + ? `${slots.length} card(s) remaining for this harvest.` + : !slots.length + ? 'No harvest slots (try reconnecting).' + : ''; + if (note.textContent) body.appendChild(note); + openPromptOverlayShell({ + title: 'Harvest', + dismissible: true, + bodyEl: body, + footerEl: null, + }); + return; + } + + if (slots.some(s => s.kind === 'citizen' && s.is_thief)) { + const thief = mk('prompt-modal-note'); + thief.textContent = 'If you have the Thief, harvest that citizen before other citizens.'; + body.appendChild(thief); + } + + const foot = mk('prompt-modal-actions prompt-modal-actions--wrap'); + slots.forEach(s => { + const ai = Number(s.activation_index); + const dup = Number.isFinite(ai) && ai > 0 ? ` · #${ai + 1}` : ''; + const ci = Number(s.card_idx); + const copy = Number.isFinite(ci) ? ` · copy ${ci + 1}` : ''; + const label = `${s.name || ''} (${s.kind} #${s.card_id}${copy}${dup})`; + const sk = (s.slot_key || '').toString(); + foot.appendChild(promptButton(`Harvest: ${label}`, () => postGameAction({ + player_id: PLAYER_ID, + action_type: 'harvest_card', + harvest_slot_key: sk, + }))); + }); + + openPromptOverlayShell({ + title: 'Harvest', + dismissible: false, + bodyEl: body, + footerEl: foot, + }); +} + +function renderBonusResourcePrompt(state) { + const req = state?.action_required || {}; + const reqId = (req?.id || '').toString(); + const isYou = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID)); + + const chip = harvestTurnChip(state, reqId); + + const body = mk('prompt-modal-body'); + const headRow = mk('prompt-modal-inline'); + const ht = mk('prompt-modal-note'); + ht.textContent = isYou ? 'Harvest bonus — choose +1 resource' : `Harvest bonus pending for ${reqId}`; + headRow.appendChild(ht); + if (chip) headRow.appendChild(chip); + body.appendChild(headRow); + + if (!isYou) { + openPromptOverlayShell({ + title: 'Harvest bonus', + dismissible: true, + bodyEl: body, + footerEl: null, + }); + return; + } + + const foot = promptActionsRow([ + promptButton('+1 Gold', () => postGameAction({ + player_id: PLAYER_ID, + action_type: 'act_on_required_action', + action: 'gold', + })), + promptButton('+1 Strength', () => postGameAction({ + player_id: PLAYER_ID, + action_type: 'act_on_required_action', + action: 'strength', + })), + promptButton('+1 Magic', () => postGameAction({ + player_id: PLAYER_ID, + action_type: 'act_on_required_action', + action: 'magic', + })), + ]); + + openPromptOverlayShell({ + title: 'Harvest bonus', + dismissible: false, + bodyEl: body, + footerEl: foot, + }); +} + +function renderUnknownRequired(state, reqAction, reqId) { + const body = mk('prompt-modal-body'); + const note = mk('prompt-modal-note'); + note.textContent = `Waiting on ${reqId}: ${reqAction}`; + body.appendChild(note); + openPromptOverlayShell({ + title: 'Waiting', + dismissible: true, + bodyEl: body, + footerEl: null, + }); +} + +function renderPromptModal(state) { + if (!GAME_ID || !PLAYER_ID) return; + + const concurrent = state?.concurrent_action || null; + const concurrentPending = concurrent && Array.isArray(concurrent.pending) ? concurrent.pending : []; + if (concurrentPending.length > 0) { + renderConcurrentPanel(state, concurrent); + return; + } + + const req = state?.action_required || {}; + const reqId = req?.id || ''; + const reqAction = (req?.action || '').toString(); + + if (!reqId || reqId === state?.game_id) { + removePromptOverlay(); + return; + } + + if (reqAction === 'standard_action') { + removePromptOverlay(); + return; + } + + if (reqAction === 'finalize_roll') { + if (finalizeRollModifierOptions(state).length === 0) { + removePromptOverlay(); + return; + } + renderFinalizeRollPrompt(state); + return; + } + + if (reqAction === 'domain_self_convert') { + renderDomainSelfConvertPrompt(state); + return; + } + + if (reqAction === 'choose_player') { + renderDomainChoosePlayer(state); + return; + } + + if (reqAction === 'choose_monster_strength') { + renderDomainChooseMonster(state); + return; + } + + if (typeof reqAction === 'string' && reqAction.trim().startsWith('choose ')) { + renderChoosePrompt(state, reqAction); + return; + } + + if (reqAction === 'harvest_optional_exchange') { + renderHarvestOptionalExchangePrompt(state); + return; + } + + if (reqAction === 'manual_harvest') { + renderManualHarvestPrompt(state); + return; + } + + if (reqAction !== 'bonus_resource_choice') { + renderUnknownRequired(state, reqAction, reqId); + return; + } + + renderBonusResourcePrompt(state); +} + +// ── Lobby modal when visiting without game_id / player_id ──────────────── +function initLobbyModal() { + const overlay = document.getElementById('lobby-overlay'); + const connEl = document.getElementById('conn-status'); + const errEl = document.getElementById('lobby-error'); + const stepJoin = document.getElementById('lobby-step-join'); + const stepWait = document.getElementById('lobby-step-wait'); + const nameInput = document.getElementById('lobby-display-name'); + const joinBtn = document.getElementById('lobby-join-btn'); + const readyBtn = document.getElementById('lobby-ready-btn'); + const leaveBtn = document.getElementById('lobby-leave-btn'); + const playerList = document.getElementById('lobby-player-list'); + const metaEl = document.getElementById('lobby-meta'); + + if (!overlay || !stepJoin || !stepWait || !joinBtn || !readyBtn || !leaveBtn || !playerList || !nameInput) { + if (connEl) connEl.textContent = 'Missing game_id or player_id in URL'; + return; + } + + let lobbyPlayerId = ''; + let lobbyWs = null; + let lobbyWsReconnectTimer = null; + let lastLobbySnapshot = null; + + function shutdownLobbySocket() { + if (lobbyWs) { + lobbyWs.onopen = null; + lobbyWs.onmessage = null; + lobbyWs.onclose = null; + lobbyWs.onerror = null; + try { + lobbyWs.close(); + } catch (_) { + /* ignore */ + } + lobbyWs = null; + } + } + + function tearDownLobbyConnection() { + if (lobbyWsReconnectTimer) { + clearTimeout(lobbyWsReconnectTimer); + lobbyWsReconnectTimer = null; + } + shutdownLobbySocket(); + } + + function setLobbyLiveStatus(mode) { + const liveEl = document.getElementById('lobby-live'); + if (!liveEl) return; + liveEl.classList.remove('lobby-live--ok', 'lobby-live--warn', 'lobby-live--off'); + if (mode === 'ok') { + liveEl.textContent = 'Live'; + liveEl.classList.add('lobby-live--ok'); + } else if (mode === 'warn') { + liveEl.textContent = 'Connecting…'; + liveEl.classList.add('lobby-live--warn'); + } else { + liveEl.textContent = 'Offline'; + liveEl.classList.add('lobby-live--off'); + } + } + + function lobbyWsUrl() { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${proto}//${location.host}/ws/lobby`; + } + + function sendLobbyIdentify() { + if (!lobbyWs || lobbyWs.readyState !== WebSocket.OPEN) return; + const pid = lobbyPlayerId || localStorage.getItem('playerId') || ''; + lobbyWs.send(JSON.stringify({ type: 'identify', player_id: pid || null })); + } + + function connectLobbyWs() { + if (lobbyWsReconnectTimer) { + clearTimeout(lobbyWsReconnectTimer); + lobbyWsReconnectTimer = null; + } + shutdownLobbySocket(); + setLobbyLiveStatus('warn'); + lobbyWs = new WebSocket(lobbyWsUrl()); + lobbyWs.onopen = () => { + setLobbyLiveStatus('ok'); + sendLobbyIdentify(); + }; + lobbyWs.onmessage = evt => { + let msg; + try { + msg = JSON.parse(evt.data); + } catch (_) { + return; + } + if (msg.type === 'lobby_status') applyLobbyStatusPayload(msg); + else if (msg.type === 'game_started') handleGameStarted(msg); + }; + lobbyWs.onclose = () => { + lobbyWs = null; + setLobbyLiveStatus('warn'); + lobbyWsReconnectTimer = setTimeout(connectLobbyWs, 2200); + }; + lobbyWs.onerror = () => { + try { + lobbyWs.close(); + } catch (_) { + /* ignore */ + } + }; + } + + function openOverlay() { + overlay.classList.add('lobby-overlay--open'); + overlay.setAttribute('aria-hidden', 'false'); + if (connEl) connEl.textContent = '● lobby'; + } + + function showLobbyError(msg) { + if (!errEl) return; + if (!msg) { + errEl.textContent = ''; + errEl.classList.add('lobby-hidden'); + return; + } + errEl.textContent = msg; + errEl.classList.remove('lobby-hidden'); + } + + function enterGameFromLobby(gameId, playerId) { + tearDownLobbyConnection(); + localStorage.setItem('playerId', playerId); + localStorage.setItem('gameId', gameId); + const q = new URLSearchParams({ game_id: gameId, player_id: playerId }); + location.replace(`${location.pathname}?${q}`); + } + + function handleGameStarted(msg) { + const pid = lobbyPlayerId || localStorage.getItem('playerId'); + const gid = msg.game_id; + const ids = msg.player_ids || []; + if (!pid || !gid) return; + if (!ids.some(x => idsMatch(x, pid))) return; + enterGameFromLobby(gid, pid); + } + + function applyLobbyStatusPayload(data) { + lastLobbySnapshot = data; + if (data.in_game && data.game_id) { + const pid = lobbyPlayerId || localStorage.getItem('playerId'); + if (pid) enterGameFromLobby(data.game_id, pid); + return; + } + const selfId = lobbyPlayerId || localStorage.getItem('playerId') || ''; + const inList = selfId && (data.lobby || []).some(x => idsMatch(x.player_id, selfId)); + if (selfId && stepWait && !stepWait.classList.contains('lobby-hidden') && !inList) { + showLobbyError('You are no longer in this lobby. Join again.'); + lobbyPlayerId = ''; + localStorage.removeItem('playerId'); + tearDownLobbyConnection(); + connectLobbyWs(); + stepWait.classList.add('lobby-hidden'); + stepJoin.classList.remove('lobby-hidden'); + return; + } + if (metaEl) { + metaEl.textContent = + typeof data.game_count === 'number' + ? `${data.game_count} active game${data.game_count === 1 ? '' : 's'} on this server` + : ''; + } + renderLobbyRows(data.lobby || [], selfId); + const self = (data.lobby || []).find(x => idsMatch(x.player_id, selfId)); + if (self) { + const ready = !!self.is_ready; + readyBtn.textContent = ready ? 'Cancel ready' : 'Ready'; + readyBtn.classList.toggle('is-cancel', ready); + } + } + + async function fetchLobbyPayload() { + const pid = lobbyPlayerId || localStorage.getItem('playerId') || ''; + const url = pid + ? `/api/lobby/status?player_id=${encodeURIComponent(pid)}` + : '/api/lobby/status'; + const res = await fetch(url); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const detail = data.detail != null ? String(data.detail) : res.statusText; + throw new Error(detail || 'Lobby request failed'); + } + return data; + } + + function renderLobbyRows(lobby, selfId) { + playerList.innerHTML = ''; + lobby.forEach(p => { + const li = document.createElement('li'); + li.className = 'lobby-player-row' + (idsMatch(p.player_id, selfId) ? ' is-self' : ''); + const nameSpan = document.createElement('span'); + nameSpan.className = 'lobby-p-name'; + nameSpan.textContent = p.name || 'Player'; + const stSpan = document.createElement('span'); + stSpan.className = 'lobby-p-status' + (p.is_ready ? ' is-ready' : ''); + stSpan.textContent = p.is_ready ? 'Ready' : 'Waiting'; + li.appendChild(nameSpan); + li.appendChild(stSpan); + playerList.appendChild(li); + }); + } + + function showWaitUi() { + stepJoin.classList.add('lobby-hidden'); + stepWait.classList.remove('lobby-hidden'); + openOverlay(); + sendLobbyIdentify(); + } + + async function tryResumeStoredPlayer() { + const saved = localStorage.getItem('playerId'); + if (!saved) return; + try { + const res = await fetch(`/api/lobby/status?player_id=${encodeURIComponent(saved)}`); + const data = await res.json(); + if (!res.ok) return; + if (data.in_game && data.game_id) { + enterGameFromLobby(data.game_id, saved); + return; + } + const stillThere = (data.lobby || []).some(p => idsMatch(p.player_id, saved)); + if (stillThere) { + lobbyPlayerId = saved; + showWaitUi(); + } + } catch (_) { + /* ignore */ + } + } + + joinBtn.addEventListener('click', async () => { + const name = nameInput.value.trim(); + if (!name) { + showLobbyError('Enter a display name.'); + return; + } + showLobbyError(''); + joinBtn.disabled = true; + try { + const res = await fetch('/api/lobby/join', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.detail != null ? String(data.detail) : res.statusText || 'Join failed'); + } + lobbyPlayerId = data.player_id || ''; + localStorage.setItem('playerId', lobbyPlayerId); + showWaitUi(); + } catch (e) { + showLobbyError(e.message || 'Could not join lobby.'); + } finally { + joinBtn.disabled = false; + } + }); + + nameInput.addEventListener('keydown', ev => { + if (ev.key === 'Enter') joinBtn.click(); + }); + + readyBtn.addEventListener('click', async () => { + const pid = lobbyPlayerId || localStorage.getItem('playerId'); + if (!pid) return; + readyBtn.disabled = true; + try { + let st = lastLobbySnapshot; + if (!st) { + try { + st = await fetchLobbyPayload(); + } catch (e) { + showLobbyError(e.message || 'Could not reach lobby.'); + return; + } + } + const self = (st.lobby || []).find(x => idsMatch(x.player_id, pid)); + const endpoint = self && self.is_ready ? '/api/lobby/unready' : '/api/lobby/ready'; + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ player_id: pid, debug_starting_resources: false }), + }); + const out = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(out.detail != null ? String(out.detail) : res.statusText || 'Ready failed'); + } + if (out.game_id) { + enterGameFromLobby(out.game_id, pid); + return; + } + } catch (e) { + showLobbyError(e.message || 'Ready toggle failed.'); + } finally { + readyBtn.disabled = false; + } + }); + + leaveBtn.addEventListener('click', async () => { + const pid = lobbyPlayerId || localStorage.getItem('playerId'); + if (!pid) return; + leaveBtn.disabled = true; + try { + await fetch(`/api/lobby/leave?player_id=${encodeURIComponent(pid)}`, { method: 'POST' }); + } catch (_) { + /* still reset UI */ + } + lobbyPlayerId = ''; + localStorage.removeItem('playerId'); + showLobbyError(''); + sendLobbyIdentify(); + stepWait.classList.add('lobby-hidden'); + stepJoin.classList.remove('lobby-hidden'); + leaveBtn.disabled = false; + }); + + openOverlay(); + connectLobbyWs(); + tryResumeStoredPlayer(); +} + +// ── Boot ────────────────────────────────────────────────────────────────── +if (!GAME_ID || !PLAYER_ID) { + initLobbyModal(); +} else { + connect(); + initOpponentTableauWheelScroll(); + window.addEventListener('resize', () => scheduleBoardLayout()); + const seat0 = document.getElementById('seat-0'); + const zoneCenter = document.getElementById('zone-center'); + if (typeof ResizeObserver !== 'undefined') { + if (seat0) new ResizeObserver(() => scheduleBoardLayout()).observe(seat0); + if (zoneCenter) new ResizeObserver(() => scheduleBoardLayout()).observe(zoneCenter); + } +} diff --git a/static/game/index.html b/static/game/index.html new file mode 100644 index 0000000..c052cc2 --- /dev/null +++ b/static/game/index.html @@ -0,0 +1,51 @@ + + + + + + Valeria Card Kingdoms + + + + Lobby +
+
+
+
+
+
+
+
+
+ + + + diff --git a/static/game/style.css b/static/game/style.css new file mode 100644 index 0000000..edef5f3 --- /dev/null +++ b/static/game/style.css @@ -0,0 +1,1574 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --felt: #1e4a2e; + --felt-dark: #152e1d; + --felt-light: #255a37; + --zone-bg: rgba(0,0,0,0.25); + --zone-border:rgba(0,0,0,0.4); + + --card-r: #5a1418; --card-r-border: #9b2335; + --card-b: #152040; --card-b-border: #2566ab; + --card-d: #2e1c00; --card-d-border: #8B6914; + --card-s: #252525; --card-s-border: #666; + --card-k: #1e0e30; --card-k-border: #7040b0; + --card-x: #141414; --card-x-border: #333; + + --gold: #dfb152; + --str: #c45433; + --mag: #8bb8e6; + --vp: #a060f0; + --text: #ddd; + --muted: #888; +} + +html, body { + width: 100%; height: 100%; + overflow: hidden; + background: var(--felt-dark); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--text); + font-size: 16px; +} + +/* ── Board ──────────────────────────────────────────────────────────── */ +.board { + position: relative; + width: 100vw; + height: 100vh; + overflow: hidden; + background: radial-gradient(ellipse at 50% 55%, var(--felt-light) 0%, var(--felt) 40%, var(--felt-dark) 100%); + --seat-edge-spread: 0px; +} + +/* ── Seats ──────────────────────────────────────────────────────────── */ +/* + Pentagon: flat bottom edge = my seat; single apex at TOP of screen. + + Circumradius R = 47vh → top vertex at (50%, 47vh − 47vh) = (50%, 0) ✓ + Apothem r = R·cos36° = 47·0.809 = 38vh + Center: (50%, 47vh) + + Side midpoints (clockwise from bottom) — same pentagon layout, all seats upright: + seat-0 bottom → anchored to screen bottom, centered + seat-1 lower-right → (cx+36.1vh, cy+11.7vh) + seat-2 upper-right → (cx+22.3vh, cy−30.7vh) + seat-3 upper-left → (cx−22.3vh, cy−30.7vh) + seat-4 lower-left → (cx−36.1vh, cy+11.7vh) + + r·sin(108°)=36.1 r·cos(108°)=11.7 r·sin(36°)=22.3 r·cos(36°)=30.7 +*/ +.seat { + position: absolute; + background: var(--zone-bg); + border: 1px solid var(--zone-border); + border-radius: 8px; + overflow: hidden; + z-index: 5; +} + +.seat.seat-empty { + background: rgba(0,0,0,0.12); + border: 1px dashed rgba(255,255,255,0.1); + opacity: 0.45; + min-height: 40px; + min-width: 80px; +} + +.seat-ghost-label { + padding: 8px 10px; + font-size: 16px; + color: var(--muted); + text-align: center; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +/* Me — full viewport width; pentagon anchor stays visually centered at bottom */ +.seat-0 { + bottom: 6px; + left: 50%; + transform: translateX(-50%); + width: calc(100vw - 16px); + max-width: calc(100vw - 16px); +} + +/* Opponents — pentagon anchors + viewport edge spread + JS repulsion nudge */ +.seat-1 { + left: calc(50% + 36.1vh); + top: 58.7vh; + width: max-content; + max-width: min(440px, calc(100vw - 24px)); + transform: translate(calc(-50% + var(--seat-nudge-x, 0px) + var(--seat-edge-spread, 0px)), calc(-50% + var(--seat-nudge-y, 0px))); +} +.seat-2 { + left: calc(50% + 22.3vh); + top: 16.3vh; + width: max-content; + max-width: min(440px, calc(100vw - 24px)); + transform: translate(calc(-50% + var(--seat-nudge-x, 0px) + var(--seat-edge-spread, 0px)), calc(-50% + var(--seat-nudge-y, 0px))); +} +.seat-3 { + left: calc(50% - 22.3vh); + top: 16.3vh; + width: max-content; + max-width: min(440px, calc(100vw - 24px)); + transform: translate(calc(-50% + var(--seat-nudge-x, 0px) - var(--seat-edge-spread, 0px)), calc(-50% + var(--seat-nudge-y, 0px))); +} +.seat-4 { + left: calc(50% - 36.1vh); + top: 58.7vh; + width: max-content; + max-width: min(440px, calc(100vw - 24px)); + transform: translate(calc(-50% + var(--seat-nudge-x, 0px) - var(--seat-edge-spread, 0px)), calc(-50% + var(--seat-nudge-y, 0px))); +} + +.seat-1.seat-empty, +.seat-2.seat-empty { + width: auto; + max-width: 260px; + transform: translate(calc(-50% + var(--seat-edge-spread, 0px)), -50%); +} + +.seat-3.seat-empty, +.seat-4.seat-empty { + width: auto; + max-width: 260px; + transform: translate(calc(-50% - var(--seat-edge-spread, 0px)), -50%); +} + +/* ── Seat inner layout ──────────────────────────────────────────────── */ +.seat-inner { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px; + min-width: 0; +} + +.seat-0 .seat-inner { + width: 100%; +} + +/* ── Player header bar ──────────────────────────────────────────────── */ +.player-header { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 4px 6px; + background: rgba(0,0,0,0.3); + border-radius: 5px; + flex-shrink: 0; +} + +.player-name { + font-weight: 700; + font-size: 16px; + color: #fff; + margin-right: 4px; +} + +.player-name.is-active::after { + content: " ▶"; + color: var(--gold); +} + +.player-name.is-first { + display: inline-flex; + align-items: center; +} + +.player-first-star { + color: var(--gold); + font-size: 16px; + line-height: 1; + margin-right: 4px; + cursor: help; +} + +.score-pill { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 1px 6px; + border-radius: 10px; + font-size: 16px; + font-weight: 600; + background: rgba(0,0,0,0.4); +} +.score-pill-resource-icon { + display: block; + height: 1.15em; + max-height: min(1.25em, 20px); + width: auto; + object-fit: contain; +} +.score-pill.gold { color: var(--gold); } +.score-pill.strength { color: var(--str); } +.score-pill.magic { color: var(--mag); } +.score-pill.victory { color: var(--vp); } + +/* ── Active player: animated ring around tableau (cards row only) ─────── */ +.tableau-ring-host { + position: relative; + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.tableau-ring-sweep { + display: none; + position: absolute; + left: 50%; + top: 50%; + width: 220%; + height: 220%; + margin-left: -110%; + margin-top: -110%; + background: conic-gradient( + from 0deg, + transparent 0deg 278deg, + rgba(240, 192, 64, 0.15) 296deg, + rgba(240, 192, 64, 0.85) 318deg, + rgba(255, 232, 160, 1) 332deg, + rgba(240, 192, 64, 0.85) 348deg, + rgba(240, 192, 64, 0.15) 358deg, + transparent 360deg + ); + animation: tableau-turn-ring-spin 2.75s linear infinite; + pointer-events: none; + z-index: 0; + will-change: transform; +} + +.tableau-ring-host.is-active-turn { + overflow: hidden; + border-radius: 10px; +} + +.tableau-ring-host.is-active-turn .tableau-ring-sweep { + display: block; +} + +.tableau-ring-host .tableau-cards { + position: relative; + z-index: 1; +} + +/* Bottom tableau: fill seat width so the shimmer reads across the bar */ +.seat-0 .tableau-ring-host { + width: 100%; +} + +/* Wide bottom strip: opaque cards hide the sweep in the middle — use a thicker “gutter” + brighter wedge + (avoid a solid zone-bg on .tableau-cards; that read as a flat dark panel vs the real shimmer on small tableaus). */ +.seat-0 .tableau-ring-host.is-active-turn { + box-sizing: border-box; + padding: 8px; + box-shadow: inset 0 0 0 1px rgba(240, 192, 64, 0.28); +} + +.seat-0 .tableau-ring-host.is-active-turn .tableau-ring-sweep { + width: 300%; + height: 300%; + margin-left: -150%; + margin-top: -150%; + background: conic-gradient( + from 0deg, + transparent 0deg 258deg, + rgba(240, 192, 64, 0.25) 272deg, + rgba(255, 244, 200, 1) 298deg, + rgba(240, 192, 64, 0.95) 316deg, + rgba(255, 214, 90, 0.75) 328deg, + rgba(240, 192, 64, 0.3) 342deg, + transparent 356deg, + transparent 360deg + ); + filter: saturate(1.25) brightness(1.08); +} + +@keyframes tableau-turn-ring-spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .tableau-ring-host.is-active-turn .tableau-ring-sweep { + display: none; + } + .tableau-ring-host.is-active-turn { + box-shadow: inset 0 0 0 2px rgba(240, 192, 64, 0.55); + } +} + +/* ── My tableau (seat-0) ────────────────────────────────────────────── */ +.tableau-cards { + display: flex; + flex-direction: row; + gap: 5px; + overflow-x: auto; + padding-bottom: 4px; + flex: 1; + align-items: flex-start; +} + +.seat-0 .tableau-cards { + justify-content: safe center; +} + +@supports not (justify-content: safe center) { + .seat-0 .tableau-cards { + justify-content: center; + } +} + +.tableau-cards::-webkit-scrollbar { height: 4px; } +.tableau-cards::-webkit-scrollbar-track { background: transparent; } +.tableau-cards::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 2px; } + +.card-group { + display: flex; + flex-direction: row; + gap: 4px; + padding-right: 6px; + border-right: 1px solid rgba(255,255,255,0.1); + flex-shrink: 0; +} +.card-group:last-child { border-right: none; } + +.card-group-label { + writing-mode: vertical-rl; + font-size: 16px; + color: var(--muted); + align-self: stretch; + display: flex; + align-items: center; + margin-right: 2px; + flex-shrink: 0; +} + +/* ── Opponent tableaus — same grouped row + horizontal scroll as seat 0, smaller cards */ +.seat:not(.seat-0):not(.seat-empty) .tableau-cards { + display: flex; + flex-direction: row; + gap: 4px; + overflow-x: auto; + padding-bottom: 4px; + flex: 1; + align-items: flex-start; + min-width: 0; +} + +.seat:not(.seat-0) .tableau-cards::-webkit-scrollbar { height: 4px; } +.seat:not(.seat-0) .tableau-cards::-webkit-scrollbar-track { background: transparent; } +.seat:not(.seat-0) .tableau-cards::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 2px; } + +.seat:not(.seat-0) .card-group-label { + font-size: 16px; +} + +/* ── Individual card ────────────────────────────────────────────────── */ +.card { + border-radius: 5px; + border: 1px solid; + padding: 5px; + display: flex; + flex-direction: column; + gap: 2px; + flex-shrink: 0; + position: relative; + cursor: default; + user-select: none; + overflow: hidden; +} +.card:hover { filter: brightness(1.2); } + +/* Card with image — image fills card, overlay sits at bottom */ +.card.card-has-image { padding: 0; } + +.card-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 4px; + display: block; +} + +.card-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 18px 5px 5px; + background: linear-gradient(transparent, rgba(0,0,0,0.88) 55%); + border-radius: 0 0 4px 4px; + display: flex; + flex-direction: column; + gap: 1px; +} + +.card-name { + font-weight: 700; + font-size: 16px; + line-height: 1.2; + color: #fff; + word-break: break-word; +} + +.card-sub { + font-size: 16px; + color: rgba(255,255,255,0.65); + line-height: 1.2; +} + +.card-extra { + font-size: 16px; + color: rgba(255,255,255,0.45); + margin-top: 1px; +} + +/* My zone: full-size cards */ +.seat-0 .card { width: 108px; min-height: 118px; height: 158px; } +.seat-0 .card:not(.card-has-image) { height: auto; min-height: 118px; } + +/* Opponent zones: text fallback — compact chips in the horizontal row */ +.seat:not(.seat-0) .card:not(.card-has-image) { + flex-direction: row; + align-items: center; + min-height: 34px; + padding: 4px 8px; + width: auto; + min-width: 68px; + max-width: 140px; +} +.seat:not(.seat-0) .card:not(.card-has-image) .card-name { + font-size: 16px; +} + +/* Opponent image cards — narrower than seat 0 to fit the pentagon seats */ +.seat:not(.seat-0) .card.card-has-image { + flex-direction: column; + width: 84px; + height: 124px; + min-height: 124px; + padding: 0; + flex-shrink: 0; +} + +.seat:not(.seat-0) .grid-stack .stack-depth { + font-size: 16px; + padding: 2px 5px; + bottom: -1px; + right: -1px; +} + +/* Card type colors */ +.card-monster { background: var(--card-r); border-color: var(--card-r-border); } +.card-citizen { background: var(--card-b); border-color: var(--card-b-border); } +.card-domain { background: var(--card-d); border-color: var(--card-d-border); } +.card-starter { background: var(--card-s); border-color: var(--card-s-border); } +.card-duke { background: var(--card-k); border-color: var(--card-k-border); } +.card-exhausted{ background: var(--card-x); border-color: var(--card-x-border); } +.card-exhausted .card-name { color: #444; } + +.card.flipped { opacity: 0.45; } + +/* ── Center board ───────────────────────────────────────────────────── */ +/* Centered horizontally above user tableau; layoutCenterBoard() sets bottom/max-height */ +.center-board { + position: absolute; + left: 50%; + right: auto; + top: auto; + bottom: 120px; + transform: translateX(-50%); + z-index: 10; + width: max-content; + max-width: calc(100vw - 16px); + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--zone-bg); + border: 1px solid var(--zone-border); + border-radius: 8px; +} + +.center-board-body { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + width: max-content; + max-width: 100%; +} + +/* Card grid scrolls when it does not fit under the 50vh / max-height cap */ +.center-board-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + width: max-content; + max-width: 100%; +} + +.center-board-scroll::-webkit-scrollbar { width: 6px; } +.center-board-scroll::-webkit-scrollbar-track { background: transparent; } +.center-board-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 3px; } + +/* ── Grid stacks (center board only — tableaus use own card sizes) ─── */ +.grid-stack { + position: relative; + flex-shrink: 0; +} + +.center-board .grid-stack .card { width: 128px; min-height: 150px; height: 188px; } +.center-board .grid-stack .card:not(.card-has-image) { height: auto; min-height: 150px; } + +/* Opponent tableau stacks (smaller cards) */ +.seat:not(.seat-0) .grid-stack .card { width: 84px; min-height: 98px; height: 124px; } +.seat:not(.seat-0) .grid-stack .card:not(.card-has-image) { height: auto; min-height: 98px; } + +.stack-depth { + position: absolute; + bottom: -2px; right: -2px; + background: rgba(0,0,0,0.7); + color: #aaa; + font-size: 16px; + padding: 2px 5px; + border-radius: 3px; + pointer-events: none; +} + +.center-board .card-slot-empty { + width: 128px; + min-height: 150px; + border: 1px dashed rgba(255,255,255,0.12); + border-radius: 5px; + flex-shrink: 0; +} + +/* ── Center board sections ──────────────────────────────────────────── */ +.center-section { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 8px 4px; +} + +.section-label { + font-size: 16px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 2px; +} + +.grid-row { + display: flex; + flex-direction: row; + gap: 5px; + flex-wrap: nowrap; +} + +.center-board .grid-row { + gap: 6px; +} + +.center-board .stack-depth { + font-size: 16px; +} + +/* ── Info bar ───────────────────────────────────────────────────────── */ +.info-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + background: rgba(0,0,0,0.35); + border-bottom: 1px solid rgba(255,255,255,0.07); + flex-shrink: 0; + flex-wrap: wrap; + width: 100%; + box-sizing: border-box; +} + +.phase-label { + font-weight: 700; + font-size: 16px; + color: #fff; +} + +.turn-label { + font-size: 16px; + color: var(--muted); +} + +.dice-display { + display: flex; + align-items: center; + gap: 5px; + margin-left: auto; +} + +.die { + width: 34px; height: 34px; + display: flex; align-items: center; justify-content: center; + background: #f5f0e8; + color: #111; + border-radius: 5px; + font-weight: 900; + font-size: 16px; + box-shadow: 0 2px 4px rgba(0,0,0,0.5); +} + +.die-sum { + font-weight: 700; + font-size: 16px; + color: var(--gold); +} + +.info-bar-take-resource { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 2px 0; +} + +.info-bar-take-label { + font-size: 16px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgba(255,255,255,0.72); + white-space: nowrap; +} + +.info-bar-take-buttons { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.info-bar-take-buttons .prompt-btn { + padding: 5px 10px; + font-size: 16px; +} + +/* ── Game log (scrolls internally when many entries) ───────────────── */ +.game-log { + flex-shrink: 0; + margin: 0 8px 8px; + min-width: 0; + align-self: stretch; + box-sizing: border-box; + background: rgba(0,0,0,0.3); + border-radius: 5px; + border: 1px solid rgba(255,255,255,0.07); + max-height: min(120px, 22vh); + min-height: 0; + overflow-y: auto; + padding: 6px 8px; + display: flex; + flex-direction: column-reverse; + gap: 2px; +} + +.game-log::-webkit-scrollbar { width: 4px; } +.game-log::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 2px; } + +.log-entry { + font-size: 16px; + color: rgba(255,255,255,0.55); + line-height: 1.4; +} +.log-entry:first-child { color: rgba(255,255,255,0.85); } + +/* ── Game over overlay ──────────────────────────────────────────────── */ +.game-over-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.75); + display: flex; + align-items: center; + justify-content: center; + z-index: 400; +} + +.game-over-panel { + background: #1a2a1a; + border: 2px solid var(--gold); + border-radius: 12px; + padding: 32px 48px; + text-align: center; + min-width: 360px; +} + +.game-over-title { + font-size: 16px; + font-weight: 900; + color: var(--gold); + margin-bottom: 20px; +} + +.score-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + padding: 8px 0; + border-bottom: 1px solid rgba(255,255,255,0.1); + font-size: 16px; +} +.score-row:last-child { border-bottom: none; } +.score-row .rank { color: var(--gold); font-weight: 700; width: 32px; } +.score-row .sname { flex: 1; text-align: left; color: #fff; font-weight: 600; } +.score-row .total { color: var(--vp); font-weight: 900; font-size: 16px; } +.score-row .breakdown { color: var(--muted); font-size: 16px; } + +/* ── Root navigation ─────────────────────────────────────────────────── */ +.game-lobby-btn { + position: fixed; + top: 8px; + left: 10px; + /* Above game-over / lobby overlays (400) so home stays reachable */ + z-index: 410; + padding: 3px 8px; + font-size: 16px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + text-decoration: none; + color: var(--muted); + background: rgba(0, 0, 0, 0.28); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 4px; +} +.game-lobby-btn:hover { + color: var(--text); + border-color: rgba(255, 255, 255, 0.22); +} + +/* ── Connection status ──────────────────────────────────────────────── */ +.conn-status { + position: fixed; + bottom: 8px; right: 10px; + font-size: 16px; + color: var(--muted); + pointer-events: none; + z-index: 50; +} +.conn-status.disconnected { color: var(--str); } + +/* ── Lobby modal (no game_id / player_id) ───────────────────────────── */ +.lobby-overlay { + display: none; + position: fixed; + inset: 0; + z-index: 400; + align-items: center; + justify-content: center; + padding: 24px 16px; + background: + radial-gradient(ellipse 120% 80% at 50% -10%, rgba(240, 192, 64, 0.09) 0%, transparent 55%), + radial-gradient(ellipse at 50% 40%, rgba(37, 90, 55, 0.55) 0%, rgba(10, 22, 14, 0.92) 65%, rgba(5, 12, 8, 0.97) 100%); + backdrop-filter: blur(14px) saturate(1.15); +} + +.lobby-overlay.lobby-overlay--open { + display: flex; +} + +.lobby-sheet { + width: min(440px, 100%); + padding: 30px 28px 24px; + border-radius: 20px; + background: + linear-gradient(145deg, rgba(52, 62, 74, 0.55) 0%, transparent 42%), + linear-gradient(165deg, rgba(28, 34, 42, 0.97) 0%, rgba(14, 17, 22, 0.99) 100%); + border: 1px solid rgba(240, 192, 64, 0.28); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.4), + 0 28px 90px rgba(0, 0, 0, 0.72), + 0 0 48px rgba(240, 192, 64, 0.06), + inset 0 1px 0 rgba(255, 255, 255, 0.07); +} + +.lobby-brand { + margin-bottom: 22px; + text-align: center; +} + +.lobby-kicker { + font-size: 16px; + letter-spacing: 0.28em; + text-transform: uppercase; + color: var(--gold); + opacity: 0.9; + margin-bottom: 8px; +} + +.lobby-title { + font-size: 16px; + font-weight: 800; + color: #fff; + letter-spacing: -0.02em; + line-height: 1.15; +} + +.lobby-tagline { + margin-top: 10px; + font-size: 16px; + line-height: 1.45; + color: var(--muted); +} + +.lobby-error { + margin-bottom: 14px; + padding: 10px 12px; + border-radius: 8px; + font-size: 16px; + color: #ffb8b8; + background: rgba(224, 80, 80, 0.15); + border: 1px solid rgba(224, 80, 80, 0.35); +} + +.lobby-hidden { + display: none !important; +} + +.lobby-label { + display: block; + font-size: 16px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 8px; +} + +.lobby-join-row { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.lobby-input { + flex: 1 1 160px; + min-width: 0; + padding: 11px 14px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(0, 0, 0, 0.35); + color: var(--text); + font-size: 16px; + outline: none; +} + +.lobby-input:focus { + border-color: rgba(240, 192, 64, 0.45); + box-shadow: 0 0 0 3px rgba(240, 192, 64, 0.12); +} + +.lobby-btn { + cursor: pointer; + border-radius: 10px; + font-size: 16px; + font-weight: 700; + padding: 11px 18px; + border: 1px solid transparent; + transition: background 0.15s, border-color 0.15s, transform 0.1s; +} + +.lobby-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.lobby-btn-primary { + background: linear-gradient(180deg, #c89830 0%, #a07820 100%); + color: #1a1206; + border-color: rgba(255, 220, 120, 0.35); +} + +.lobby-btn-primary:hover:not(:disabled) { + filter: brightness(1.06); +} + +.lobby-btn-ready { + flex: 1; + background: rgba(64, 208, 128, 0.18); + color: var(--vp); + border-color: rgba(64, 208, 128, 0.35); +} + +.lobby-btn-ready.is-cancel { + background: rgba(255, 255, 255, 0.06); + color: var(--text); + border-color: rgba(255, 255, 255, 0.14); +} + +.lobby-btn-ready:hover:not(:disabled) { + filter: brightness(1.08); +} + +.lobby-btn-ghost { + background: transparent; + color: var(--muted); + border-color: rgba(255, 255, 255, 0.12); +} + +.lobby-btn-ghost:hover:not(:disabled) { + color: var(--text); + border-color: rgba(255, 255, 255, 0.22); +} + +.lobby-hint { + font-size: 16px; + color: var(--muted); + line-height: 1.45; + margin-bottom: 14px; +} + +.lobby-player-list { + list-style: none; + max-height: min(220px, 40vh); + overflow-y: auto; + margin-bottom: 16px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(0, 0, 0, 0.22); +} + +.lobby-player-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + font-size: 16px; +} + +.lobby-player-row:last-child { + border-bottom: none; +} + +.lobby-player-row.is-self { + background: rgba(240, 192, 64, 0.07); +} + +.lobby-p-name { + font-weight: 600; + color: #fff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lobby-p-status { + flex-shrink: 0; + font-size: 16px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 4px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: var(--muted); +} + +.lobby-p-status.is-ready { + background: rgba(64, 208, 128, 0.2); + color: var(--vp); +} + +.lobby-actions { + display: flex; + gap: 10px; + align-items: stretch; +} + +.lobby-footer { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgba(255, 255, 255, 0.07); + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 16px; +} + +.lobby-live { + font-size: 16px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted); +} + +.lobby-live::before { + content: ''; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--muted); + opacity: 0.85; +} + +.lobby-live--ok { + color: rgba(180, 245, 200, 0.95); +} + +.lobby-live--ok::before { + background: var(--vp); + box-shadow: 0 0 10px rgba(64, 208, 128, 0.55); + animation: lobby-pulse 2.4s ease-in-out infinite; +} + +.lobby-live--warn::before { + background: var(--gold); + animation: lobby-pulse 1.2s ease-in-out infinite; +} + +.lobby-live--off::before { + background: var(--str); + animation: none; + opacity: 1; +} + +@keyframes lobby-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.55; transform: scale(0.92); } +} + +.lobby-meta { + margin: 0; + flex: 1 1 auto; + text-align: right; + font-size: 16px; + color: var(--muted); + min-width: min(100%, 200px); +} + +@media (max-width: 380px) { + .lobby-meta { + text-align: left; + width: 100%; + } +} + +/* ── Card hover preview ─────────────────────────────────────────────── */ +.card-preview { + display: none; + position: fixed; + z-index: 200; + pointer-events: none; + border-radius: 6px; + box-shadow: 0 8px 32px rgba(0,0,0,0.7); + /* show at native resolution — no width/height override */ +} + +/* ── Card click modal ───────────────────────────────────────────────── */ +.card-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.78); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; +} + +.card-modal { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 28px; + align-items: flex-start; + justify-content: center; + background: #1a1a2a; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 12px; + padding: 28px; + box-sizing: border-box; + width: max-content; + max-width: calc(100vw - 24px); + max-height: 90vh; + overflow-x: hidden; + overflow-y: auto; + box-shadow: 0 16px 64px rgba(0,0,0,0.8); +} + +.card-modal-img { + display: block; + width: auto; + height: auto; + max-height: 75vh; + max-width: min(560px, calc(100vw - 120px)); + border-radius: 6px; + flex-shrink: 1; + object-fit: contain; + box-shadow: 0 4px 16px rgba(0,0,0,0.6); +} + +.card-modal-info { + display: flex; + flex-direction: column; + gap: 10px; + flex: 1 1 260px; + min-width: 0; + max-width: min(520px, calc(100vw - 48px)); +} + +.modal-card-name { + font-size: 16px; + font-weight: 800; + color: #fff; + line-height: 1.2; +} + +.modal-stat-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 4px 0; + border-bottom: 1px solid rgba(255,255,255,0.07); + font-size: 16px; +} + +.modal-stat-label { + color: var(--muted); + white-space: nowrap; +} + +.modal-stat-value { color: var(--text); font-weight: 600; min-width: 0; text-align: right; overflow-wrap: break-word; } +.modal-stat-value.modal-gold { color: var(--gold); } +.modal-stat-value.modal-str { color: var(--str); } +.modal-stat-value.modal-mag { color: var(--mag); } +.modal-stat-value.modal-vp { color: var(--vp); } + +.modal-resource-inline { + display: inline-flex; + align-items: center; + gap: 4px; + vertical-align: middle; +} + +.modal-resource-icon { + display: block; + height: 1.15em; + max-height: min(1.25em, 22px); + width: auto; + object-fit: contain; +} + +.modal-card-text { + font-size: 16px; + color: rgba(255,255,255,0.65); + line-height: 1.5; + margin-top: 4px; + padding-top: 10px; + border-top: 1px solid rgba(255,255,255,0.1); + overflow-wrap: break-word; + word-break: break-word; +} + +.card-modal--market { + max-width: calc(100vw - 24px); +} + +.card-modal--market .card-modal-info { + flex: 1 1 320px; + min-width: 0; + max-width: min(600px, calc(100vw - 48px)); +} + +.market-rules-extra { + border-top-color: rgba(255,255,255,0.06); +} + +.market-action-panel { + margin-top: 14px; + padding-top: 16px; + border-top: 1px solid rgba(255,255,255,0.12); + display: flex; + flex-direction: column; + gap: 10px; +} + +.market-action-heading { + font-size: 16px; + font-weight: 800; + color: #fff; +} + +.market-resources-row { + font-size: 16px; + font-weight: 600; + color: rgba(255,255,255,0.72); +} + +.market-resources-row--strip { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.market-resources-intro { + color: rgba(255,255,255,0.72); + margin-right: 2px; +} + +.market-effects-banner { + font-size: 16px; + line-height: 1.4; + color: rgba(255,255,255,0.8); + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgba(240,192,64,0.25); + background: rgba(240,192,64,0.08); +} + +.market-block-note { + font-size: 16px; + font-weight: 700; + color: var(--str); +} + +.market-cost-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + font-size: 16px; + color: rgba(255,255,255,0.75); + line-height: 1.45; + overflow-wrap: break-word; +} + +.market-afford-ok { + font-size: 16px; + font-weight: 700; + color: var(--vp); +} + +.market-afford-bad { + font-size: 16px; + font-weight: 700; + color: rgba(224,80,80,0.95); +} + +.market-pay-fields { + margin-top: 4px; +} + +.market-pay-row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 14px; +} + +.market-pay-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.market-pay-field-label { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 16px; + font-weight: 800; + letter-spacing: 0.06em; + color: var(--muted); + text-transform: uppercase; +} + +.market-pay-field-label--gold { color: var(--gold); text-transform: none; letter-spacing: normal; } +.market-pay-field-label--strength { color: var(--str); text-transform: none; letter-spacing: normal; } +.market-pay-field-label--magic { color: var(--mag); text-transform: none; letter-spacing: normal; } + +.market-pay-label-icon { + display: block; + height: 1.15em; + max-height: 20px; + width: auto; + object-fit: contain; +} + +.market-pay-input { + width: 64px; + padding: 8px 8px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,0.18); + background: rgba(0,0,0,0.35); + color: #fff; + font-size: 16px; + font-weight: 700; +} + +.market-pay-input:focus { + outline: none; + border-color: rgba(240,192,64,0.45); +} + +.market-pay-input:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.market-primary-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 8px; +} + +.market-action-help { + font-size: 16px; + color: var(--muted); + line-height: 1.4; +} + +.prompt-btn:disabled, +.prompt-btn-secondary:disabled { + opacity: 0.42; + cursor: not-allowed; +} + +/* ── Required-choice modal (reuses card-modal shell) ─────────────────── */ +.game-prompt-overlay { + z-index: 320; +} + +.card-modal--prompt { + flex-direction: column; + flex-wrap: nowrap; + align-items: stretch; + gap: 18px; + width: auto; + min-width: min(92vw, 360px); + max-width: min(calc(100vw - 24px), 720px); +} + +.prompt-modal-head { + display: flex; + flex-direction: column; + gap: 8px; +} + +.prompt-modal-title { + margin: 0; +} + +.prompt-modal-subtitle { + font-size: 16px; + font-weight: 600; + color: var(--muted); + line-height: 1.35; +} + +.prompt-modal-body { + display: flex; + flex-direction: column; + gap: 12px; +} + +.prompt-modal-note { + font-size: 16px; + color: rgba(255,255,255,0.72); + line-height: 1.45; + overflow-wrap: break-word; +} + +.prompt-modal-inline { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.prompt-modal-footer { + padding-top: 4px; + border-top: 1px solid rgba(255,255,255,0.1); +} + +.prompt-modal-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.prompt-modal-actions--wrap { + justify-content: flex-start; +} + +.prompt-modal-dice-line { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + font-size: 16px; + font-weight: 700; + color: #fff; +} + +.prompt-turn-chip { + font-size: 16px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.2); + background: rgba(255,255,255,0.06); + color: var(--muted); +} + +.prompt-turn-chip.is-on-turn { + border-color: rgba(64,208,128,0.45); + background: rgba(64,208,128,0.12); + color: var(--vp); +} + +.prompt-btn { + font: inherit; + font-size: 16px; + font-weight: 700; + padding: 10px 16px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.18); + background: linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0.06)); + color: #fff; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} + +.prompt-btn:hover { + border-color: rgba(240,192,64,0.45); + background: linear-gradient(180deg, rgba(240,192,64,0.2), rgba(240,192,64,0.08)); +} + +.prompt-btn-secondary { + border-color: rgba(255,255,255,0.12); + background: rgba(255,255,255,0.04); + font-weight: 600; + color: var(--muted); +} + +.prompt-btn-secondary:hover { + border-color: rgba(255,255,255,0.22); + color: var(--text); +} + +.prompt-choice-list { + display: flex; + flex-direction: column; + gap: 12px; + max-height: 52vh; + overflow-x: hidden; + overflow-y: auto; + padding-right: 4px; +} + +.prompt-choice-card { + background: rgba(0,0,0,0.22); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 10px; + padding: 14px 16px; +} + +.prompt-choice-card-inner { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 16px; +} + +.prompt-choice-card-img-wrap { + flex-shrink: 0; +} + +.prompt-choice-card-img { + display: block; + width: auto; + height: auto; + max-height: min(36vh, 240px); + max-width: min(100%, 240px); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.55); +} + +.prompt-choice-card-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +@media (max-width: 520px) { + .prompt-choice-card-inner { + flex-direction: column; + align-items: stretch; + } + + .prompt-choice-card-img { + max-width: 100%; + max-height: min(40vh, 260px); + margin: 0 auto; + } +} + +.prompt-choice-card-title { + font-size: 16px; + font-weight: 800; + color: #fff; +} + +.prompt-choice-card-scaling { + font-size: 16px; + font-weight: 700; + line-height: 1.45; + color: var(--gold); + letter-spacing: 0.02em; +} + +.prompt-choice-card-meta { + font-size: 16px; + color: var(--muted); + margin-top: 4px; +} + +.prompt-choice-card-text { + font-size: 16px; + color: rgba(255,255,255,0.62); + line-height: 1.45; + margin-top: 8px; + white-space: pre-wrap; +} + +.prompt-choice-card-actions { + margin-top: 12px; +} + +/* cards are now interactive */ +.card { cursor: pointer; }