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,
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"])

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.

315
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.")
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 <citizens where role==soldier and gold_cost<=2>"
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

View File

@@ -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),

View File

@@ -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,

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