Compare commits

..

1 Commits

Author SHA1 Message Date
0ea67f62e2 need to commit more 2026-05-02 23:25:02 -07:00
168 changed files with 5600 additions and 65 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -378,3 +378,22 @@ class Duke(Card):
return cls(duke_id, name, gold_mult, strength_mult, magic_mult, shadow_mult, holy_mult, soldier_mult, 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, worker_mult, monster_mult, citizen_mult, domain_mult, boss_mult, minion_mult, beast_mult,
titan_mult, expansion) 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"])

181
docs/effect-strings.md Normal file
View File

@@ -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 <citizens>` | 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 <citizens where role==shadow>` | Prompt: take a shadow citizen |
| Cloudrider's Camp | `s 3 + choose <citizens where role==soldier and gold_cost<=2>` | 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 <citizens where name==Knight>` | Pick one: +3g or take a Knight citizen |
| Ettercap | `choose <citizens where gold_cost<=2>` | Take a citizen worth ≤2g |
| Spider Queen | `choose <count area Forest g 2> <citizens + v 1>` | 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 <citizens where gold_cost<=3>` | Take a citizen worth ≤3g |
| Orc Batrider | `choose <citizens>` | 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 <citizens where name==Knight> # pick a resource amount OR an entity
choose <citizens where gold_cost<=2> # 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 <citizens where name==Knight>`.
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 <citizens where role==shadow> → no change
s 3 + choose <citizens where role==soldier and gold_cost<=2> → 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.

285
game.py
View File

@@ -93,6 +93,28 @@ def _validate_monster_slay_payment(player, strength_cost, magic_min, gp, sp, mp)
raise ValueError("Insufficient resources.") 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. # 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_two = game_state.get('rolled_die_two', self.die_two)
self.rolled_die_sum = game_state.get('rolled_die_sum', self.die_sum) self.rolled_die_sum = game_state.get('rolled_die_sum', self.die_sum)
self.exhausted_count = game_state['exhausted_count'] 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.effects = game_state['effects']
self.action_required = game_state['action_required'] self.action_required = game_state['action_required']
# Concurrent (non-ordered) prompt: all listed players must respond before progression. # Concurrent (non-ordered) prompt: all listed players must respond before progression.
@@ -237,6 +262,8 @@ class Game:
self.last_active_time = 0 self.last_active_time = 0
self.game_log = list(game_state.get('game_log') or []) 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_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) # 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. # 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 self.pending_roll = game_state.get('pending_roll') or None
@@ -285,6 +312,9 @@ class Game:
Advance the game by one deterministic tick. Advance the game by one deterministic tick.
This is intentionally small-grained so the server can call it implicitly. 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. # Block on any active concurrent (non-ordered) prompt first.
if self.is_blocked_on_concurrent_action(): if self.is_blocked_on_concurrent_action():
return False return False
@@ -292,8 +322,15 @@ class Game:
# Block only on required player choices (not on standard action prompts) # Block only on required player choices (not on standard action prompts)
if self.action_required and self.action_required.get("id") and self.action_required.get("id") != self.game_id: if self.action_required and self.action_required.get("id") and self.action_required.get("id") != self.game_id:
aa = str(self.action_required.get("action", "") or "") aa = str(self.action_required.get("action", "") or "")
if self.action_required.get("action") == "bonus_resource_choice" or aa.startswith("choose ") or aa.startswith( if (
"choose_player") or aa.startswith("choose_monster") or aa == "domain_self_convert": 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 return False
if self.phase == "setup": if self.phase == "setup":
@@ -337,8 +374,17 @@ class Game:
if self.pending_action_end_queue: if self.pending_action_end_queue:
return False return False
finisher = self._player_label(self.current_player_id()) 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_index = (self.turn_index + 1) % max(1, len(self.player_list))
self.turn_number = int(self.turn_number) + 1 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.phase = 'roll'
self.actions_remaining = 0 self.actions_remaining = 0
self.action_required["id"] = self.game_id 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": if aid and aid != self.game_id and aact and aact != "standard_action":
return False return False
finisher = self._player_label(self.current_player_id()) 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_index = (self.turn_index + 1) % max(1, len(self.player_list))
self.turn_number = int(self.turn_number) + 1 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.phase = 'roll'
self.actions_remaining = 0 self.actions_remaining = 0
# Leaving action phase: clear the standard action prompt. # 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 we're blocked on a required choice, no standard actions can be taken.
if self.action_required and self.action_required.get("id") and self.action_required.get("id") != self.game_id: if self.action_required and self.action_required.get("id") and self.action_required.get("id") != self.game_id:
aa = str(self.action_required.get("action", "") or "") aa = str(self.action_required.get("action", "") or "")
if self.action_required.get("action") in ("bonus_resource_choice", "manual_harvest") or aa.startswith( if self.action_required.get("action") in (
"choose ") or aa.startswith("choose_player") or aa.startswith("choose_monster") or aa == "domain_self_convert": "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 return False
if player_id != self.current_player_id(): if player_id != self.current_player_id():
@@ -970,7 +1030,7 @@ class Game:
if not aid or aid == self.game_id: if not aid or aid == self.game_id:
return False return False
aa = self.action_required.get("action") or "" 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 return True
if str(aa).startswith("choose ") or str(aa).startswith("choose_player") or str(aa).startswith("choose_monster"): if str(aa).startswith("choose ") or str(aa).startswith("choose_player") or str(aa).startswith("choose_monster"):
return True return True
@@ -1099,6 +1159,8 @@ class Game:
p.harvest_delta = {"gold": 0, "strength": 0, "magic": 0, "victory": 0} p.harvest_delta = {"gold": 0, "strength": 0, "magic": 0, "victory": 0}
active = self._player_by_id(self.current_player_id()) active = self._player_by_id(self.current_player_id())
self._apply_harvest_jousting_passive(active) self._apply_harvest_jousting_passive(active)
self._silent_harvest_batch = True
try:
order = self._harvest_player_id_order_starting_active() order = self._harvest_player_id_order_starting_active()
for pid in order: for pid in order:
player = self._player_by_id(pid) player = self._player_by_id(pid)
@@ -1114,6 +1176,8 @@ class Game:
for slot in slots: for slot in slots:
self._apply_harvest_activation(player, slot["_obj"], slot["kind"], on_turn) self._apply_harvest_activation(player, slot["_obj"], slot["kind"], on_turn)
consumed.append(slot["slot_key"]) consumed.append(slot["slot_key"])
finally:
self._silent_harvest_batch = False
for player in self.player_list: for player in self.player_list:
print(f"Player {player.name}: {player.gold_score} G, {player.strength_score} S, {player.magic_score} M," 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)}, " f" {player.victory_score} VP, Monsters: {len(player.owned_monsters)}, "
@@ -1128,12 +1192,37 @@ class Game:
return return
self._harvest_run_automation_until_blocked() 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 +. Execute multiple commands separated by +.
e.g. "s 3 + choose <citizens where role==soldier and gold_cost<=2>" e.g. "s 3 + choose <citizens where role==soldier and gold_cost<=2>"
Non-choice commands are executed immediately and return [result]. Non-choice commands are executed immediately and return [result].
Choice commands set action_required and return [0,0,0,0]. 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(" + ")] parts = [p.strip() for p in (compound_command or "").split(" + ")]
if not parts: if not parts:
@@ -1144,6 +1233,9 @@ class Game:
return [-9999, 0, 0, 0] return [-9999, 0, 0, 0]
prior_action = (self.action_required or {}).get("action", "") prior_action = (self.action_required or {}).get("action", "")
prior_concurrent = getattr(self, "concurrent_action", None) 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: for cmd in parts:
if not cmd: if not cmd:
continue continue
@@ -1151,6 +1243,8 @@ class Game:
cmd, cmd,
player_id, player_id,
auto_apply_single_choice=auto_apply_single_choice, 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_action = (self.action_required or {}).get("action", "")
new_concurrent = getattr(self, "concurrent_action", None) new_concurrent = getattr(self, "concurrent_action", None)
@@ -1165,13 +1259,24 @@ class Game:
if prior_empty: if prior_empty:
return payout return payout
return total_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[0] += payout[0]
total_payout[1] += payout[1] total_payout[1] += payout[1]
total_payout[2] += payout[2] total_payout[2] += payout[2]
total_payout[3] += payout[3] total_payout[3] += payout[3]
return total_payout 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") print("executing special payout")
raw = (command or "").strip() raw = (command or "").strip()
low = raw.lower() low = raw.lower()
@@ -1185,6 +1290,8 @@ class Game:
raw, raw,
player_id, player_id,
auto_apply_single_choice=auto_apply_single_choice, 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 payout = [0, 0, 0, 0] # gp, sp, mp, vp, todo: citizen, monster, domain
split_command = (command or "").split() split_command = (command or "").split()
@@ -1246,6 +1353,11 @@ class Game:
case _: case _:
payout[0] = -9999 payout[0] = -9999
case "exchange": case "exchange":
player_x = self._player_by_id(player_id)
if not player_x:
payout[0] = -9999
print(payout)
return payout
match second_word: match second_word:
case 'g': case 'g':
payout[0] = payout[0] - int(third_word) payout[0] = payout[0] - int(third_word)
@@ -1268,6 +1380,27 @@ class Game:
payout[3] = payout[3] + int(split_command[4]) payout[3] = payout[3] + int(split_command[4])
case _: case _:
payout[0] = -9999 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": case "choose":
normalized, options = self._normalize_choose_command(command) normalized, options = self._normalize_choose_command(command)
options = self._filter_unavailable_choose_options(options) options = self._filter_unavailable_choose_options(options)
@@ -2282,6 +2415,47 @@ class Game:
self.action_required['id'] = self.game_id self.action_required['id'] = self.game_id
return 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 {} prc0 = getattr(self, "pending_required_choice", None) or {}
if prc0.get("kind") == "domain_boost_monster" and str(current_required).strip() == "choose_monster_strength": if prc0.get("kind") == "domain_boost_monster" and str(current_required).strip() == "choose_monster_strength":
act = (action or "").strip().lower() act = (action or "").strip().lower()
@@ -2455,12 +2629,18 @@ class Game:
self._log_game_event(f"All players finished: {ca.get('kind')}.") self._log_game_event(f"All players finished: {ca.get('kind')}.")
handler.finalize(self) handler.finalize(self)
self.concurrent_action = None self.concurrent_action = None
# If we were stalled in setup, drive the engine forward so the # Drive the engine forward after the concurrent action resolves.
# next state the client polls is something actionable.
if self.phase == "setup": if self.phase == "setup":
# Setup stall: advance until the first actionable state.
while self.advance_tick(): while self.advance_tick():
if self.phase == "action": if self.phase == "action":
break 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): def update_payout_for_role(self, role_name, player_id, payout, split_command):
role_count = 0 role_count = 0
@@ -2560,6 +2740,10 @@ class Game:
if citizen_stack: if citizen_stack:
citizen_stack[-1].toggle_accessibility(True) 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) after = self._player_scores_line(player)
pay = self._format_resource_payment(gp, sp, mp) pay = self._format_resource_payment(gp, sp, mp)
self._log_game_event( self._log_game_event(
@@ -2688,6 +2872,10 @@ class Game:
if domain_stack: if domain_stack:
domain_stack[-1].toggle_visibility(True) domain_stack[-1].toggle_visibility(True)
domain_stack[-1].toggle_accessibility(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) self._apply_domain_activation_effect(player, bought)
after = self._player_scores_line(player) after = self._player_scores_line(player)
pay = self._format_resource_payment(gp, sp, mp) pay = self._format_resource_payment(gp, sp, mp)
@@ -2736,6 +2924,85 @@ class Game:
self.harvest_phase() self.harvest_phase()
self.action_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): def end_check(self):
if self.exhausted_count <= (len(self.player_list) * 2): if self.exhausted_count <= (len(self.player_list) * 2):
return False return False

View File

@@ -1,6 +1,6 @@
from json import JSONEncoder 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 from game_models import GameMember, LobbyMember, Player
@@ -72,6 +72,8 @@ class GameObjectEncoder(JSONEncoder):
return obj.to_dict() return obj.to_dict()
if isinstance(obj, Domain): if isinstance(obj, Domain):
return obj.to_dict() 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"): if hasattr(obj, "game_id") and hasattr(obj, "player_list") and hasattr(obj, "monster_grid"):
ca_raw = getattr(obj, "concurrent_action", None) ca_raw = getattr(obj, "concurrent_action", None)
ca_enc = ca_raw ca_enc = ca_raw
@@ -91,6 +93,9 @@ class GameObjectEncoder(JSONEncoder):
"rolled_die_sum": getattr(obj, "rolled_die_sum", obj.die_sum), "rolled_die_sum": getattr(obj, "rolled_die_sum", obj.die_sum),
"pending_roll": getattr(obj, "pending_roll", None), "pending_roll": getattr(obj, "pending_roll", None),
"exhausted_count": obj.exhausted_count, "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, "effects": obj.effects,
"action_required": obj.action_required, "action_required": obj.action_required,
"pending_required_choice": getattr(obj, "pending_required_choice", None), "pending_required_choice": getattr(obj, "pending_required_choice", None),

View File

@@ -1,7 +1,7 @@
import random import random
from typing import List 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 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 = [] domain_stack = []
duke_query = "select_random_dukes" duke_query = "select_random_dukes"
duke_stack = [] duke_stack = []
exhausted_stack = [Exhausted(i) for i in range(len(player_list_from_lobby) * 2)]
starter_query = "SELECT * FROM starters" starter_query = "SELECT * FROM starters"
starter_stack = [] starter_stack = []
player_list = [] player_list = []
@@ -256,6 +257,7 @@ def load_game_data(game_id, preset, player_list_from_lobby, debug_starting_resou
game_state = { game_state = {
"game_id": game_id, "game_id": game_id,
"pending_required_choice": None,
"player_list": player_list, "player_list": player_list,
"monster_grid": monster_grid, "monster_grid": monster_grid,
"citizen_grid": citizen_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_two": die_two,
"die_sum": die_sum, "die_sum": die_sum,
"exhausted_count": exhausted_count, "exhausted_count": exhausted_count,
"exhausted_stack": exhausted_stack,
"end_game_triggered": False,
"final_scores": None,
"effects": effects, "effects": effects,
"action_required": action_required, "action_required": action_required,
"concurrent_action": None, "concurrent_action": None,

BIN
images/.DS_Store vendored Normal file

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

Before

Width:  |  Height:  |  Size: 546 KiB

After

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

Before

Width:  |  Height:  |  Size: 509 KiB

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Some files were not shown because too many files have changed in this diff Show More