Compare commits
1 Commits
2690d538f4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ea67f62e2 |
19
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"])
|
||||
|
||||
181
docs/effect-strings.md
Normal 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
@@ -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,6 +1159,8 @@ 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)
|
||||
self._silent_harvest_batch = True
|
||||
try:
|
||||
order = self._harvest_player_id_order_starting_active()
|
||||
for pid in order:
|
||||
player = self._player_by_id(pid)
|
||||
@@ -1114,6 +1176,8 @@ class Game:
|
||||
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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 546 KiB After Width: | Height: | Size: 546 KiB |
BIN
images/domains/domain_01_jousting_field.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
images/domains/domain_02_ancient_tomb.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
images/domains/domain_03_foxgrove_palisade.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
images/domains/domain_04_the_desert_orchid.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
images/domains/domain_05_pretorius_conclave.jpg
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
images/domains/domain_06_emerald_stronghold.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
images/domains/domain_07_pratchetts_plateau.jpg
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
images/domains/domain_08_shelley_commons.jpg
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
images/domains/domain_09_cathedral_of_st_aquila.jpg
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
images/domains/domain_10_cursed_cavern.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
images/domains/domain_11_darktide_harbor.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
images/domains/domain_12_king_tower.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
images/domains/domain_13_cloudriders_camp.jpg
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
images/domains/domain_14_the_orb_of_urdr.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
images/domains/domain_15_wisborg.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 509 KiB After Width: | Height: | Size: 509 KiB |
BIN
images/domains/domain_r01c01.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
images/domains/domain_r01c03.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
images/domains/domain_r01c04.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
images/domains/domain_r01c05.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
images/domains/domain_r01c07.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
images/domains/domain_r01c09.jpg
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
images/domains/domain_r01c10.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
images/domains/domain_r02c01.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
images/domains/domain_r02c02.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
images/domains/domain_r02c03.jpg
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
images/domains/domain_r02c05.jpg
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
images/domains/domain_r02c06.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
images/domains/domain_r03c01.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
images/domains/domain_r03c02.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
images/domains/domain_r03c03.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
images/domains/domain_r03c04.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
images/domains/domain_r03c05.jpg
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
images/domains/domain_r03c06.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
images/domains/domain_r03c07.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
images/domains/domain_r03c09.jpg
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
images/domains/domain_r03c10.jpg
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
images/domains/domain_r04c01.jpg
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
images/domains/domain_r04c02.jpg
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
images/domains/domain_r04c03.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
images/domains/domain_r04c04.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
images/domains/domain_r04c05.jpg
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
images/domains/domain_r04c06.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
images/domains/domain_r04c07.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
images/domains/domain_r04c08.jpg
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
images/domains/domain_r04c09.jpg
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
images/domains/domain_r04c10.jpg
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
images/domains/domain_r05c01.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
images/domains/domain_r05c02.jpg
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
images/domains/domain_r05c03.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
images/domains/domain_r05c04.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
images/domains/domain_r05c05.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
images/domains/domain_r05c06.jpg
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
images/domains/domain_r05c07.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
images/domains/domain_r05c08.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
images/domains/domain_r05c10.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
images/domains/domain_r06c01.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
images/domains/domain_r06c02.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
images/domains/domain_r06c03.jpg
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
images/domains/domain_r06c05.jpg
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
images/domains/domain_r06c06.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
images/domains/domain_r06c07.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
images/domains/domain_r06c09.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
images/domains/domain_r06c10.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
images/domains/domain_r07c01.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
images/domains/domain_r07c02.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
images/domains/domain_r07c03.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
images/domains/domain_r07c04.jpg
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
images/domains/domain_r07c06.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
images/domains/domain_r07c08.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
images/domains/domain_r07c09.jpg
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
images/dukes/duke_01_elisium_the_allsmith.jpg
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
images/dukes/duke_02_reese_the_firebrand.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
images/dukes/duke_03_tsoukalos_the_conspirator.jpg
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
images/dukes/duke_04_cornelius_the_dreamer.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
images/dukes/duke_05_mico_the_monster_slayer.jpg
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
images/dukes/duke_06_waybright_the_wise.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
images/dukes/duke_07_mulholland_the_brave.jpg
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
images/dukes/duke_08_hrothgar_the_conqueror.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
images/dukes/duke_09_isabella_the_righteous.jpg
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
images/dukes/duke_10_elsyn_saint_of_shadows.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
images/dukes/duke_11_waryn_lord_of_rogues.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |