From 5ff452ba2c9b71001edb4279276af658025be19d Mon Sep 17 00:00:00 2001 From: Luke Esau Date: Mon, 27 Apr 2026 08:45:44 -0700 Subject: [PATCH] expanded basic game features --- activate_with_env.sh | 18 + cards.py | 55 +- check_db_server.py | 40 + docs/README.md | 13 + docs/README_SERVER.md | 79 + docs/database.md | 64 + docs/dev-setup.md | 33 + docs/game.md | 121 ++ docs/server.md | 88 + docs/testing.md | 31 + game.py | 1395 +++++++++++++-- requirements.txt | 22 + server.py | 2382 ++++++++++++++++++++++++++ setup_venv.sh | 40 + sql/INSTALL_PROCEDURES.md | 107 ++ sql/USER_SETUP_GUIDE.md | 129 ++ sql/citizens_202304121016.sql | 2 +- sql/create_all_stored_procedures.sql | 76 + sql/fix_user_setup.sql | 56 + sql/run_sql.sh | 63 + test_database.py | 359 ++++ 21 files changed, 5001 insertions(+), 172 deletions(-) create mode 100755 activate_with_env.sh create mode 100644 check_db_server.py create mode 100644 docs/README.md create mode 100644 docs/README_SERVER.md create mode 100644 docs/database.md create mode 100644 docs/dev-setup.md create mode 100644 docs/game.md create mode 100644 docs/server.md create mode 100644 docs/testing.md create mode 100644 requirements.txt create mode 100644 server.py create mode 100755 setup_venv.sh create mode 100644 sql/INSTALL_PROCEDURES.md create mode 100644 sql/USER_SETUP_GUIDE.md create mode 100644 sql/create_all_stored_procedures.sql create mode 100644 sql/fix_user_setup.sql create mode 100755 sql/run_sql.sh create mode 100644 test_database.py diff --git a/activate_with_env.sh b/activate_with_env.sh new file mode 100755 index 0000000..262d0a5 --- /dev/null +++ b/activate_with_env.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Helper script to activate venv and set MariaDB environment variables + +# Activate virtual environment +source .venv/bin/activate + +# Find and set MARIADB_CONFIG +MARIADB_CONFIG_PATH=$(find /opt/homebrew -name mariadb_config 2>/dev/null | head -1) + +if [ -n "$MARIADB_CONFIG_PATH" ]; then + export MARIADB_CONFIG="$MARIADB_CONFIG_PATH" + echo "✓ MARIADB_CONFIG set to: $MARIADB_CONFIG_PATH" +else + echo "⚠ Warning: mariadb_config not found. Install with: brew install mariadb-connector-c" +fi + +echo "Virtual environment activated and ready to use!" + diff --git a/cards.py b/cards.py index eb5e6fe..2f892f9 100644 --- a/cards.py +++ b/cards.py @@ -1,3 +1,14 @@ +def _coerce_int(val, default=0): + if val is None: + return default + if isinstance(val, bool): + return int(val) + try: + return int(val) + except (TypeError, ValueError): + return default + + class Card: def __init__(self): self.name = "" @@ -81,10 +92,10 @@ class Citizen(Card): self.gold_cost = gold_cost self.roll_match1 = roll_match1 self.roll_match2 = roll_match2 - self.shadow_count = shadow_count - self.holy_count = holy_count - self.soldier_count = soldier_count - self.worker_count = worker_count + self.shadow_count = _coerce_int(shadow_count) + self.holy_count = _coerce_int(holy_count) + self.soldier_count = _coerce_int(soldier_count) + self.worker_count = _coerce_int(worker_count) self.gold_payout_on_turn = gold_payout_on_turn self.gold_payout_off_turn = gold_payout_off_turn self.strength_payout_on_turn = strength_payout_on_turn @@ -112,6 +123,12 @@ class Citizen(Card): "holy_count": self.holy_count, "soldier_count": self.soldier_count, "worker_count": self.worker_count, + "roles": { + "shadow": self.shadow_count, + "holy": self.holy_count, + "soldier": self.soldier_count, + "worker": self.worker_count, + }, "gold_payout_on_turn": self.gold_payout_on_turn, "gold_payout_off_turn": self.gold_payout_off_turn, "strength_payout_on_turn": self.strength_payout_on_turn, @@ -132,10 +149,10 @@ class Citizen(Card): gold_cost=dict_["gold_cost"], roll_match1=dict_["roll_match1"], roll_match2=dict_["roll_match2"], - shadow_count=dict_["shadow_count"], - holy_count=dict_["holy_count"], - soldier_count=dict_["soldier_count"], - worker_count=dict_["worker_count"], + shadow_count=dict_.get("shadow_count"), + holy_count=dict_.get("holy_count"), + soldier_count=dict_.get("soldier_count"), + worker_count=dict_.get("worker_count"), gold_payout_on_turn=dict_["gold_payout_on_turn"], gold_payout_off_turn=dict_["gold_payout_off_turn"], strength_payout_on_turn=dict_["strength_payout_on_turn"], @@ -157,10 +174,10 @@ class Domain(Card): self.domain_id = domain_id self.name = name self.gold_cost = gold_cost - self.shadow_count = shadow_count - self.holy_count = holy_count - self.soldier_count = soldier_count - self.worker_count = worker_count + self.shadow_count = _coerce_int(shadow_count) + self.holy_count = _coerce_int(holy_count) + self.soldier_count = _coerce_int(soldier_count) + self.worker_count = _coerce_int(worker_count) self.vp_reward = vp_reward self.has_activation_effect = has_activation_effect self.has_passive_effect = has_passive_effect @@ -179,6 +196,12 @@ class Domain(Card): "holy_count": self.holy_count, "soldier_count": self.soldier_count, "worker_count": self.worker_count, + "roles": { + "shadow": self.shadow_count, + "holy": self.holy_count, + "soldier": self.soldier_count, + "worker": self.worker_count, + }, "vp_reward": self.vp_reward, "has_activation_effect": self.has_activation_effect, "has_passive_effect": self.has_passive_effect, @@ -194,10 +217,10 @@ class Domain(Card): domain_id=dict_['domain_id'], name=dict_['name'], gold_cost=dict_['gold_cost'], - shadow_count=dict_['shadow_count'], - holy_count=dict_['holy_count'], - soldier_count=dict_['soldier_count'], - worker_count=dict_['worker_count'], + shadow_count=dict_.get('shadow_count'), + holy_count=dict_.get('holy_count'), + soldier_count=dict_.get('soldier_count'), + worker_count=dict_.get('worker_count'), vp_reward=dict_['vp_reward'], has_activation_effect=dict_['has_activation_effect'], has_passive_effect=dict_['has_passive_effect'], diff --git a/check_db_server.py b/check_db_server.py new file mode 100644 index 0000000..8dee4fa --- /dev/null +++ b/check_db_server.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +Simple script to check if MariaDB/MySQL server is running +Doesn't require mariadb module - just checks if port is open +""" + +import socket +import sys + +def check_database_server(): + """Check if database server is listening on port 3306""" + print("Checking if MariaDB/MySQL server is accessible on localhost:3306...") + print("=" * 50) + print("(Make sure SSH port forwarding is active: ssh -L 3306:localhost:3306 lukesau.com)") + + host = '127.0.0.1' + port = 3306 + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + result = sock.connect_ex((host, port)) + sock.close() + + if result == 0: + print(f"✓ Database server is accessible at {host}:{port}") + return True + else: + print(f"✗ Cannot reach {host}:{port}") + print("\nMake sure SSH port forwarding is active:") + print(" ssh -L 3306:localhost:3306 lukesau.com") + return False + except Exception as e: + print(f"✗ Error checking server: {e}") + return False + +if __name__ == "__main__": + success = check_database_server() + sys.exit(0 if success else 1) + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..add1a86 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,13 @@ +# VCK Online Docs + +This folder contains developer documentation for the VCK Online dev/test server and game engine. + +## Start here + +- `README_SERVER.md`: how to run the FastAPI server and use the dev HTML client +- `dev-setup.md`: local environment setup (venv + MariaDB connector) +- `database.md`: database expectations, SSH tunnel, and stored procedure setup +- `server.md`: FastAPI server architecture and API surface +- `game.md`: game engine model and the DB-backed game bootstrap flow +- `testing.md`: how to run the included test scripts + diff --git a/docs/README_SERVER.md b/docs/README_SERVER.md new file mode 100644 index 0000000..5920260 --- /dev/null +++ b/docs/README_SERVER.md @@ -0,0 +1,79 @@ +# VCK Online FastAPI Server + +Simple REST API server for developing and testing the Valeria Card Kingdoms Online game. + +## Setup + +1. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +2. Make sure database is accessible (SSH tunnel if needed): + ```bash + ssh -L 3306:localhost:3306 lukesau.com + ``` + +## Running the Server + +```bash +python3 server.py +``` + +Or with uvicorn directly: +```bash +uvicorn server:app --host 0.0.0.0 --port 8000 --reload +``` + +The server will start on `http://localhost:8000` + +## API Endpoints + +### Lobby + +- `POST /api/lobby/join` - Join lobby with name + ```json + {"name": "Player Name"} + ``` + Returns: `{"player_id": "...", "message": "Joined lobby"}` + +- `POST /api/lobby/ready` - Mark player as ready + ```json + {"player_id": "..."} + ``` + Returns game info if all players ready + +- `POST /api/lobby/unready` - Mark player as not ready +- `POST /api/lobby/leave?player_id=...` - Leave lobby +- `GET /api/lobby/status?player_id=...` - Get lobby status + +### Game + +- `GET /api/game/{game_id}/state` - Get current game state +- `POST /api/game/{game_id}/action` - Perform game action + ```json + { + "player_id": "...", + "action_type": "hire_citizen|buy_domain|slay_monster|act_on_required_action|roll_phase|harvest_phase|play_turn", + "citizen_id": 123, // for hire_citizen + "domain_id": 456, // for buy_domain + "monster_id": 789, // for slay_monster + "gold_cost": 5, // for hire_citizen/buy_domain + "strength_cost": 3, // for slay_monster + "magic_cost": 0, // optional + "action": "choose 1" // for act_on_required_action + } + ``` + +## Web Client + +Visit `http://localhost:8000` for a simple HTML client to test the API. + +## Development Notes + +- Games are stored in-memory (will be lost on server restart) +- Inactive games are cleaned up after 3 minutes of no activity +- Inactive lobby players are removed after 60 seconds +- This is a development/testing server, not production-ready + + diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..2aa78d5 --- /dev/null +++ b/docs/database.md @@ -0,0 +1,64 @@ +# Database + +## Overview + +The game bootstrap in `game.py` loads card data from a MariaDB database named `vckonline` using stored procedures to select card sets and randomize stacks. + +The code assumes it can connect to: + +- host: `127.0.0.1` +- port: `3306` +- user: `vckonline` +- password: `vckonline` +- database: `vckonline` + +This is designed to work with an SSH tunnel that forwards the remote DB to local port 3306. + +## SSH tunnel + +Keep an SSH port forward running while using the DB locally: + +```bash +ssh -L 3306:localhost:3306 lukesau.com +``` + +## Stored procedures + +The server/game code expects these procedures to exist: + +- `select_base1_monsters()` +- `select_base1_citizens()` +- `select_base2_monsters()` +- `select_base2_citizens()` +- `select_random_domains()` +- `select_random_dukes()` + +To install all procedures: + +```bash +./sql/run_sql.sh sql/create_all_stored_procedures.sql +``` + +See `sql/INSTALL_PROCEDURES.md` for additional options (mysql client, interactive MariaDB session, installing individually). + +## User / grants setup + +If you have authentication or permissions problems, use: + +- `sql/USER_SETUP_GUIDE.md`: investigation and fix commands (create users for `localhost`, `127.0.0.1`, `%`, and grant privileges) +- `sql/fix_user_setup.sql`: a convenience SQL script in this repo (if you prefer to run a script vs copy/paste commands) + +## Verifying the DB + +Quick port-level check (no Python deps): + +```bash +python3 check_db_server.py +``` + +Full end-to-end check (Python + DB + tables + stored procs): + +```bash +python3 test_database.py +``` + diff --git a/docs/dev-setup.md b/docs/dev-setup.md new file mode 100644 index 0000000..0df6d46 --- /dev/null +++ b/docs/dev-setup.md @@ -0,0 +1,33 @@ +# Dev setup + +## Python environment + +This repo expects a Python venv in `.venv/`. + +The easiest path on macOS is to use the helper script: + +```bash +./setup_venv.sh +``` + +That script: + +- Creates (or reuses) `.venv/` +- Tries to locate `mariadb_config` under `/opt/homebrew` +- Installs `mariadb-connector-c` via Homebrew if needed +- Exports `MARIADB_CONFIG` and runs `pip install -r requirements.txt` + +If you already have `.venv/` created and just want to activate with the MariaDB environment variable: + +```bash +source ./activate_with_env.sh +``` + +## Requirements + +Dependencies are listed in `requirements.txt` and include: + +- `fastapi` + `uvicorn` (API server) +- `mariadb` (DB access; requires MariaDB Connector/C to build) +- `shortuuid` (player id generation) + diff --git a/docs/game.md b/docs/game.md new file mode 100644 index 0000000..6ea6d99 --- /dev/null +++ b/docs/game.md @@ -0,0 +1,121 @@ +# Game engine (`game.py`) + +## Key types + +`game.py` defines the core runtime objects used by the server: + +- `Game`: contains the current game board state and implements actions/phases +- `Player`: per-player scores and owned cards +- `LobbyMember` / `GameMember`: lightweight records used by the server for lobby/game membership + +It also provides: + +- `load_game_data(...)`: builds an initial `game_state` dict by pulling data from MariaDB and dealing stacks +- `SummaryEncoder` and `GameObjectEncoder`: JSON encoders used by the server to serialize game state + +## Game lifecycle + +At runtime (via the server): + +- `server.py` calls `load_game_data(game_id, preset, game_gamers)` to create a starting `game_state` +- The server wraps that dict in a `Game` object: `Game(game_state)` +- The server exposes the game via `/api/game/{game_id}/state` and `/api/game/{game_id}/action` + +The `Game` object tracks: + +- `player_list` +- `monster_grid`, `citizen_grid`, `domain_grid` +- dice: `die_one`, `die_two`, `die_sum` +- `effects` +- `action_required` (used to block on per-player, sequential “choose …” actions) +- `concurrent_action` (used to block on non-ordered, multi-player prompts; see “Concurrent actions” below) +- `last_active_time` (used by the server for cleanup) + +## DB-backed bootstrap (`load_game_data`) + +`load_game_data` is responsible for: + +- Fetching cards using stored procedures: + - citizens/monsters depend on the `preset` (e.g. `"base1"` / `"base2"`) + - domains and dukes are randomized via procedures + - starters are selected via a direct `SELECT * FROM starters` +- Creating `Player` instances from the lobby/game membership list +- Randomizing player order and dealing initial cards +- Dealing stacks onto the board: + - monsters grouped by area, then 5 areas selected + - citizens grouped by roll match, placed into 10 stacks (special-cased for roll 11) + - domains dealt into 5 stacks of 3, with the top visible/accessible + +This function currently assumes local DB connectivity via `127.0.0.1:3306` (typically via SSH port forward). + +See `docs/database.md` for DB setup and stored procedure installation. + +## Actions & phases + +The server routes map `action_type` strings to methods on `Game`. + +Common paths: + +- `roll_phase()` rolls two dice and computes a sum +- `harvest_phase()` pays out from owned starters/citizens for all players based on the roll +- `hire_citizen(...)`, `buy_domain(...)`, `slay_monster(...)` mutate board stacks and player resources + +### “choose …” actions + +Some special payouts set `action_required` and start a background thread that waits until `act_on_required_action` updates `action_required` with a choice. + +This is a dev-oriented approach; it allows the REST API to supply a follow-up choice via `act_on_required_action` while the game engine waits. + +## Concurrent actions (non-ordered prompts) + +Some prompts are not turn-based: every participating player should be able to +respond at the same time, in any order, and the game must wait until **all** +of them have submitted before progressing. The starting duke selection is the +first example — every player simultaneously discards down to one of the dukes +they were dealt. + +These are modeled with `Game.concurrent_action`, which is independent from +`action_required`: + +``` +concurrent_action = { + "kind": "choose_duke", # routes to a handler in CONCURRENT_HANDLERS + "pending": ["pid1", "pid3"], + "completed": ["pid2"], + "responses": { "pid2": }, + "data": { ... } # handler-specific extras (often empty) +} +``` + +Engine semantics: + +- While `concurrent_action.pending` is non-empty, `advance_tick()` returns + `False`. No phase transitions happen, no harvest progresses, and no + per-player turn actions are accepted. `is_blocked_on_concurrent_action()` + exposes the same predicate. +- Players submit via `Game.submit_concurrent_action(player_id, response, kind=...)`. + The handler's `apply()` validates and applies that player's response + immediately (so per-player effects don't have to wait for the others). +- When the last pending player submits, the handler's `finalize()` runs + (for any cross-player resolution), `concurrent_action` is cleared, and + if the engine was sitting in setup it advances forward. + +### Adding a new concurrent action kind + +1. Implement a handler class with: + - `apply(self, game, player_id, response)` — validate + apply per-player + side effects. Raise `ValueError` to reject a submission (the player + stays in `pending`). + - `finalize(self, game)` — optional; runs once after every participant + has submitted. +2. Register it in `CONCURRENT_HANDLERS` keyed by `kind`. +3. Build the prompt with `_new_concurrent_action(kind, participant_ids, data=...)` + and assign it to `game.concurrent_action` at the point in the engine + where the gate should appear. +4. On the client, register a renderer in the `CONCURRENT_RENDERERS` map in + the dev client (server.py HTML) keyed on the same `kind`. + +Because the engine itself only knows "block while pending is non-empty", +the concurrent gate is fully reusable — no engine changes are required to +add new kinds (mulligan, simultaneous discard, voting, etc.). + diff --git a/docs/server.md b/docs/server.md new file mode 100644 index 0000000..be5e16e --- /dev/null +++ b/docs/server.md @@ -0,0 +1,88 @@ +# Server (`server.py`) + +## What it is + +`server.py` is a FastAPI development server that: + +- Maintains an in-memory lobby (`lobby`) +- Starts games when all lobby players are ready +- Stores active games in-memory (`games`) +- Exposes REST endpoints for lobby operations and game actions +- Serves a simple HTML test client at `/` + +This server is intended for development/testing, not production. + +## In-memory state + +The server keeps three top-level collections: + +- `lobby`: list of `LobbyMember` (players waiting to start a game) +- `games`: dict of `game_id -> Game` +- `gamers`: list of `GameMember` (player_id/name/game_id records for in-game players) + +There is no persistence; restarting the server resets everything. + +## Lobby flow + +High-level flow: + +- `POST /api/lobby/join`: creates a `LobbyMember` with a `shortuuid` `player_id` +- `POST /api/lobby/ready`: marks the player ready; when all lobby players are ready (and there are at least 2), it starts a game: + - generates a new `game_id` (uuid4) + - moves ready lobby members into `gamers` for that `game_id` + - calls `load_game_data(game_id, "base1", game_gamers)` and constructs `Game(game_state)` +- `GET /api/lobby/status`: returns lobby members + whether the requesting player is already in a game + +Lobby cleanup: + +- `GET /api/lobby/status` prunes lobby members inactive for > 60 seconds + +## Game API + +- `GET /api/game/{game_id}/state`: returns the current game state encoded using `GameObjectEncoder` (from `game.py`) +- `POST /api/game/{game_id}/action`: performs a game action and returns the updated game state + +Supported `action_type` values currently include: + +- `hire_citizen` +- `buy_domain` +- `slay_monster` +- `take_resource` +- `harvest_card` +- `act_on_required_action` (sequential, single-player follow-ups) +- `submit_concurrent_action` (non-ordered, multi-player prompts; see below) +- `roll_phase` +- `harvest_phase` +- `play_turn` + +### `submit_concurrent_action` + +Used to respond to a `concurrent_action` gate (see `docs/game.md`). The +serialized game state exposes a `concurrent_action` object with `kind`, +`pending`, and `completed` lists; while `pending` is non-empty no other +turn-based action will succeed. Request body: + +``` +{ + "player_id": "", + "action_type": "submit_concurrent_action", + "kind": "choose_duke", // optional sanity check + "response": "" // handler-specific payload +} +``` + +The server validates that the player is in `pending` and that `kind` +(if provided) matches the active gate. Players may submit in any order; +when the last pending player submits, the engine auto-advances out of +the setup gate. + +## Game cleanup + +On startup, a background task deletes games inactive for > 180 seconds and prunes the corresponding `gamers` entries. + +## Dev HTML client + +The root route `/` serves a simple HTML page that calls the lobby endpoints and can fetch a game state. + +For run instructions, see `docs/README_SERVER.md`. + diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..c2790ed --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,31 @@ +# Testing & diagnostics + +## Database connectivity + +### Port-level check + +`check_db_server.py` checks whether `127.0.0.1:3306` is reachable (useful to verify your SSH tunnel). + +```bash +python3 check_db_server.py +``` + +### End-to-end DB validation + +`test_database.py` does a more complete validation: + +- imports `mariadb` +- connects to the DB +- checks required tables exist +- prints row counts and card contents (citizens/monsters/domains/dukes/starters) +- checks required stored procedures exist +- calls stored procedures and prints returned rows + +```bash +python3 test_database.py +``` + +## API server smoke test + +See `docs/README_SERVER.md` to run the FastAPI server and use the built-in HTML client at `/`. + diff --git a/game.py b/game.py index ac6c1fc..c594502 100644 --- a/game.py +++ b/game.py @@ -8,6 +8,141 @@ from cards import * import threading +def _n(x, default=0): + try: + return int(x) + except (TypeError, ValueError): + return default + + +def _validate_hire_or_domain_gold_payment(player, scaled_gold_cost, gp, sp, mp): + gp, sp, mp = _n(gp), _n(sp), _n(mp) + if gp < 0 or sp < 0 or mp < 0: + raise ValueError("Invalid payment (negative amounts).") + if sp != 0: + raise ValueError("Strength cannot be spent on hiring citizens or buying domains.") + scaled_gold_cost = int(scaled_gold_cost or 0) + if scaled_gold_cost > 0 and mp > 0 and gp < 1: + raise ValueError("Must pay at least 1 gold to use magic as wild.") + total = gp + mp + if total < scaled_gold_cost: + raise ValueError("Payment does not cover the gold cost.") + if total != scaled_gold_cost: + raise ValueError("Payment must exactly match the gold cost.") + if int(getattr(player, "gold_score", 0)) < gp or int(getattr(player, "magic_score", 0)) < mp: + raise ValueError("Insufficient resources.") + + +def _citizen_is_thief(citizen): + if not citizen: + return False + name = (getattr(citizen, "name", None) or "").strip().lower() + if name == "thief": + return True + sc = getattr(citizen, "special_citizen", None) + try: + if int(sc) == 1: + return True + except (TypeError, ValueError): + pass + return False + + +def _validate_monster_slay_payment(player, strength_cost, magic_min, gp, sp, mp): + gp, sp, mp = _n(gp), _n(sp), _n(mp) + if gp != 0: + raise ValueError("Gold cannot be spent on slaying monsters.") + strength_cost = int(strength_cost or 0) + magic_min = int(magic_min or 0) + if sp < 0 or mp < 0 or mp < magic_min: + raise ValueError("Invalid monster payment.") + wild_magic = mp - magic_min + if sp + wild_magic < strength_cost: + raise ValueError("Payment does not cover strength cost.") + if strength_cost > 0 and wild_magic > 0 and sp < 1: + raise ValueError("Must pay at least 1 strength to use magic as wild for slaying.") + if int(getattr(player, "strength_score", 0)) < sp or int(getattr(player, "magic_score", 0)) < mp: + raise ValueError("Insufficient resources.") + + +# --------------------------------------------------------------------------- +# Concurrent (non-ordered) action subsystem. +# +# A "concurrent action" is a gate where many players must each submit a +# response before the game can advance, but their submissions are unordered +# (any participant may respond at any time). This is intentionally separate +# from the per-player `action_required` field, which is used for sequential, +# turn-based prompts (e.g. action phase, manual harvest). +# +# To add a new kind, register a handler in CONCURRENT_HANDLERS. The handler +# implements: +# +# apply(game, player_id, response) +# Validate + apply this player's response. Raise ValueError on bad +# input. The response payload is opaque to the engine (handler-defined). +# +# finalize(game) +# Optional. Runs once after every participant has submitted. Use this +# for any cross-player resolution that has to happen after all +# responses are in. Side-effects on individual players that don't +# depend on others should generally happen in apply(). +# +# The engine itself only knows: "while there's a concurrent_action with +# pending players, do not advance". +# --------------------------------------------------------------------------- + + +class _ChooseDukeConcurrentHandler: + """Each player keeps exactly one of their dealt dukes.""" + + def apply(self, game, player_id, response): + try: + chosen_id = int(str(response).strip()) + except Exception: + raise ValueError("Invalid duke selection.") + for p in game.player_list: + if p.player_id != player_id: + continue + dukes = list(getattr(p, "owned_dukes", []) or []) + if not dukes: + raise ValueError("No dukes to choose from.") + chosen = None + for d in dukes: + if int(getattr(d, "duke_id", -1)) == chosen_id: + chosen = d + break + if chosen is None: + raise ValueError("Selected duke not found.") + p.owned_dukes = [chosen] + return + raise ValueError("Player not found.") + + def finalize(self, game): + return + + +CONCURRENT_HANDLERS = { + "choose_duke": _ChooseDukeConcurrentHandler(), +} + +# Append-only server log included in serialized game state (same for every client). +_GAME_LOG_MAX = 400 + + +def _new_concurrent_action(kind, participant_ids, data=None): + """Build a concurrent_action dict for the given kind + participants.""" + if kind not in CONCURRENT_HANDLERS: + raise ValueError(f"Unknown concurrent action kind: {kind}") + pids = [pid for pid in participant_ids if pid] + return { + "kind": kind, + "pending": list(pids), + "completed": [], + "responses": {}, + "data": dict(data or {}), + } + + class Game: def __init__(self, game_state): self.game_id = game_state['game_id'] @@ -15,110 +150,701 @@ class Game: self.monster_grid = game_state['monster_grid'] self.citizen_grid = game_state['citizen_grid'] self.domain_grid = game_state['domain_grid'] + # Finalized dice (used for all game logic checks, harvest matching, etc.). self.die_one = game_state['die_one'] self.die_two = game_state['die_two'] self.die_sum = game_state['die_sum'] + # Rolled dice (what the RNG produced before any reroll/rig/effect adjustment). + # These are what the client should display on the dice graphic. + self.rolled_die_one = game_state.get('rolled_die_one', self.die_one) + 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.effects = game_state['effects'] self.action_required = game_state['action_required'] + # Concurrent (non-ordered) prompt: all listed players must respond before progression. + # See module-level _ChooseDukeConcurrentHandler / CONCURRENT_HANDLERS for the protocol. + self.concurrent_action = game_state.get('concurrent_action') or None + # Turn/tick tracking + self.tick_id = game_state.get('tick_id', 0) + self.turn_number = game_state.get('turn_number', 1) + self.turn_index = game_state.get('turn_index', 0) + # roll -> roll_pending -> harvest -> action + self.phase = game_state.get('phase', 'roll') + self.actions_remaining = game_state.get('actions_remaining', 0) + self.harvest_processed = game_state.get('harvest_processed', False) + self.pending_harvest_choices = game_state.get('pending_harvest_choices', []) + # Manual harvest session (None = not in a multi-step harvest resolution) + self.harvest_player_order = game_state.get('harvest_player_order') + self.harvest_player_idx = game_state.get('harvest_player_idx', 0) + self.harvest_consumed = game_state.get('harvest_consumed') or {} self.last_active_time = 0 + self.game_log = list(game_state.get('game_log') or []) + # Between roll and harvest we allow a small "finalization window" where effects (or dev rigging) + # may legally change the dice. When present, the engine blocks in roll_pending until finalized. + self.pending_roll = game_state.get('pending_roll') or None + + # If players were dealt multiple dukes, prompt every such player to keep exactly one. + # This is a concurrent (non-ordered) action: any player may choose at any time, and + # the game does not advance into roll/harvest/action until everyone has chosen. + if not self.concurrent_action: + duke_choosers = [ + p.player_id for p in self.player_list + if getattr(p, "owned_dukes", None) and len(p.owned_dukes) > 1 + ] + if duke_choosers: + self.concurrent_action = _new_concurrent_action("choose_duke", duke_choosers) + # Make sure setup-phase advance_tick blocks on the concurrent action. + if self.phase in ("roll", "harvest", "action"): + self.phase = "setup" + + if not self.game_log: + self._log_game_event("Game started.") + if self.concurrent_action and self.concurrent_action.get("kind") == "choose_duke": + self._log_game_event("Waiting for each player to choose a duke to keep.") + + def current_player_id(self): + if not self.player_list: + return None + if self.turn_index < 0 or self.turn_index >= len(self.player_list): + self.turn_index = 0 + return self.player_list[self.turn_index].player_id + + def start_new_turn_if_needed(self): + if self.phase != 'roll': + return + if self.actions_remaining != 0: + self.actions_remaining = 0 + + def is_blocked_on_concurrent_action(self): + """True iff a concurrent (non-ordered) prompt still has pending participants.""" + ca = getattr(self, "concurrent_action", None) or None + if not ca: + return False + return bool(ca.get("pending")) + + def advance_tick(self): + """ + Advance the game by one deterministic tick. + This is intentionally small-grained so the server can call it implicitly. + """ + # Block on any active concurrent (non-ordered) prompt first. + if self.is_blocked_on_concurrent_action(): + return False + + # Block only on required player choices (not on standard action prompts) + if self.action_required and self.action_required.get("id") and self.action_required.get("id") != self.game_id: + if self.action_required.get("action") == "bonus_resource_choice" or str( + self.action_required.get("action", "")).startswith("choose"): + return False + + if self.phase == "setup": + # Setup only progresses when required choices are resolved. + # Once no longer blocked, begin the normal turn loop. + blocked_ar = bool( + self.action_required + and self.action_required.get("id") + and self.action_required.get("id") != self.game_id + ) + if not blocked_ar: + self.phase = "roll" + self.tick_id += 1 + self._log_game_event("Setup complete; turns begin.") + return True + return False + + if self.phase == 'roll': + self.roll_phase() + self.tick_id += 1 + who = self._player_label(self.current_player_id()) + rd1 = int(getattr(self, "rolled_die_one", 0) or 0) + rd2 = int(getattr(self, "rolled_die_two", 0) or 0) + rds = int(getattr(self, "rolled_die_sum", rd1 + rd2) or (rd1 + rd2)) + self._log_game_event( + f"Turn {int(self.turn_number)} ({who}): rolled {rd1}+{rd2}={rds}." + ) + return True + + if self.phase == 'roll_pending': + # Waiting for the roll to be finalized (possibly changed by an effect / dev rig). + return False + + if self.phase == 'harvest': + # Manual harvest: players resolve matching starters/citizens in turn order (active player first). + if not getattr(self, "harvest_processed", False): + if getattr(self, "harvest_player_order", None) is None: + for p in self.player_list: + p.harvest_delta = {"gold": 0, "strength": 0, "magic": 0, "victory": 0} + self.harvest_consumed = {} + self.harvest_player_idx = 0 + self.harvest_player_order = self._harvest_player_id_order_starting_active() + self._harvest_run_automation_until_blocked() + + # If harvest triggered a required choice, pause progression here. + if self.action_required and self.action_required.get("id") and self.action_required.get("id") != self.game_id: + self.phase = 'harvest' + self.tick_id += 1 + if self.action_required.get("action") == "manual_harvest": + return False + return True + + self.phase = 'action' + # baseline actions per turn; may become effect-driven later + self.actions_remaining = max(0, int(self.actions_remaining) or 2) + # During action phase, mark that we're waiting on the active player to act. + self.action_required["id"] = self.current_player_id() + self.action_required["action"] = "standard_action" + self.tick_id += 1 + ap = self._player_label(self.current_player_id()) + self._log_game_event( + f"Harvest finished; {ap}'s action phase ({int(self.actions_remaining)} action(s))." + ) + return True + + if self.phase == 'action': + # Action ticks are driven by explicit player actions; if we're out of actions, advance seat. + if int(self.actions_remaining) > 0: + # Ensure action_required stays on the active player during their action window. + self.action_required["id"] = self.current_player_id() + self.action_required["action"] = "standard_action" + return False + finisher = self._player_label(self.current_player_id()) + self.turn_index = (self.turn_index + 1) % max(1, len(self.player_list)) + self.turn_number = int(self.turn_number) + 1 + self.phase = 'roll' + self.actions_remaining = 0 + # Leaving action phase: clear the standard action prompt. + self.action_required["id"] = self.game_id + self.action_required["action"] = "" + self.tick_id += 1 + self._log_game_event(f"{finisher} ended their turn.") + + # Auto-run the beginning-of-turn roll/harvest so the game lands in action phase. + progressed = False + while self.phase in ('roll', 'harvest'): + if not self.advance_tick(): + break + progressed = True + return True or progressed + + # Unknown phase; reset safely + self.phase = 'roll' + self.tick_id += 1 + return True + + def consume_player_action(self, player_id): + """ + Consume one standard action for the active player. + + When this drops actions_remaining to 0, the turn is not advanced here: + the caller must apply the hire/buy/slay/take first, then call + finish_turn_if_no_actions_remaining() so logs and engine state stay ordered. + """ + if self.phase != 'action': + # If an action comes in early, fast-forward to action phase. + while self.advance_tick(): + if self.phase == 'action': + break + + # If we're blocked on a required choice, no standard actions can be taken. + if self.action_required and self.action_required.get("id") and self.action_required.get("id") != self.game_id: + if self.action_required.get("action") in ("bonus_resource_choice", "manual_harvest") or str( + self.action_required.get("action", "")).startswith("choose"): + return False + + if player_id != self.current_player_id(): + return False + + if self.actions_remaining is None: + self.actions_remaining = 2 + if int(self.actions_remaining) <= 0: + return False + + self.actions_remaining = int(self.actions_remaining) - 1 + self.tick_id += 1 + # Keep standard action prompt while actions remain. + if int(self.actions_remaining) > 0: + self.action_required["id"] = self.current_player_id() + self.action_required["action"] = "standard_action" + + return True + + def finish_turn_if_no_actions_remaining(self): + """After a successful standard action, advance roll/harvest if the turn was just spent.""" + if getattr(self, "phase", None) == "action" and int(getattr(self, "actions_remaining", 0) or 0) == 0: + self.advance_tick() def roll_phase(self): - self.die_one = random.randint(1, 6) - self.die_two = random.randint(1, 6) - self.die_sum = self.die_one + self.die_two - print(f"{self.die_one} | {self.die_two} | {self.die_sum}") - # check for player effects that are able to change roll - # check for board effects that trigger from rolls + # Roll the RNG dice first (display value). + d1 = random.randint(1, 6) + d2 = random.randint(1, 6) + ds = d1 + d2 + self.rolled_die_one = d1 + self.rolled_die_two = d2 + self.rolled_die_sum = ds + + # Start a "pending roll" window. For now we always open the window; later effects + # can choose to auto-finalize or pause based on game state. + self.pending_roll = {"rolled_die_one": d1, "rolled_die_two": d2, "rolled_die_sum": ds} + self.phase = "roll_pending" + self.action_required["id"] = self.current_player_id() + self.action_required["action"] = "finalize_roll" + + # Default final dice are unset until finalized. + # (We intentionally do not touch self.die_one/die_two here.) + + def finalize_roll(self, player_id, die_one=None, die_two=None): + if self.phase != "roll_pending": + raise ValueError("Not waiting to finalize a roll") + if player_id != self.current_player_id(): + raise ValueError("Only the active player may finalize the roll") + + rolled = self.pending_roll or {} + rd1 = int(rolled.get("rolled_die_one") or 0) + rd2 = int(rolled.get("rolled_die_two") or 0) + if rd1 < 1 or rd1 > 6 or rd2 < 1 or rd2 > 6: + raise ValueError("Pending roll is invalid") + + fd1 = rd1 if die_one is None else int(die_one) + fd2 = rd2 if die_two is None else int(die_two) + if fd1 < 1 or fd1 > 6 or fd2 < 1 or fd2 > 6: + raise ValueError("Final dice must be between 1 and 6") + + self.die_one = fd1 + self.die_two = fd2 + self.die_sum = fd1 + fd2 + + self.pending_roll = None + # Move into harvest exactly like the old post-roll transition. + self.phase = "harvest" + self.harvest_processed = False + self.harvest_player_order = None + self.harvest_player_idx = 0 + self.harvest_consumed = {} + + # Clear the finalize prompt; harvest/action will set prompts as needed. + self.action_required["id"] = self.game_id + self.action_required["action"] = "" + self.tick_id += 1 + who = self._player_label(self.current_player_id()) + if fd1 == rd1 and fd2 == rd2: + self._log_game_event( + f"Turn {int(self.turn_number)} ({who}): roll finalized at {fd1}+{fd2}={self.die_sum}." + ) + else: + self._log_game_event( + f"Turn {int(self.turn_number)} ({who}): roll changed {rd1}+{rd2}={rd1+rd2} -> {fd1}+{fd2}={self.die_sum}." + ) + + def _player_by_id(self, player_id): + for p in self.player_list: + if p.player_id == player_id: + return p + return None + + def _player_label(self, player_id): + if not player_id: + return "?" + p = self._player_by_id(player_id) + if p and getattr(p, "name", None): + return p.name + return str(player_id)[:8] + + def _player_scores_line(self, player): + if not player: + return "G?/S?/M?/VP?" + g = int(getattr(player, "gold_score", 0) or 0) + s = int(getattr(player, "strength_score", 0) or 0) + m = int(getattr(player, "magic_score", 0) or 0) + v = int(getattr(player, "victory_score", 0) or 0) + return f"G{g}/S{s}/M{m}/VP{v}" + + def _format_resource_payment(self, gp, sp, mp): + gp, sp, mp = _n(gp), _n(sp), _n(mp) + if gp == 0 and sp == 0 and mp == 0: + return "no gold/strength/magic spent" + parts = [] + if gp: + parts.append(f"{gp} gold") + if sp: + parts.append(f"{sp} strength") + if mp: + parts.append(f"{mp} magic") + return "spent " + ", ".join(parts) + + def _log_game_event(self, message): + if not hasattr(self, "game_log") or self.game_log is None: + self.game_log = [] + self.game_log.append({ + "tick": int(getattr(self, "tick_id", 0) or 0), + "msg": str(message), + }) + while len(self.game_log) > _GAME_LOG_MAX: + self.game_log.pop(0) + + def _harvest_player_id_order_starting_active(self): + n = len(self.player_list) + if n == 0: + return [] + t = int(self.turn_index) % n + return [self.player_list[(t + i) % n].player_id for i in range(n)] + + def _roll_match_count(self, card): + d1, d2, ds = self.die_one, self.die_two, self.die_sum + rm1 = getattr(card, "roll_match1", None) + rm2 = getattr(card, "roll_match2", None) + if rm1 is None: + return False, 0 + try: + rm2 = int(rm2) if rm2 is not None else 0 + except (TypeError, ValueError): + rm2 = 0 + if (rm1 == d1) or (rm1 == d2) or (rm1 == ds) or (rm2 == ds): + count = 2 if rm1 == d1 == d2 else 1 + return True, count + return False, 0 + + def _bump_harvest_delta(self, player, dg, ds, dm, dv=0): + hd = player.harvest_delta + hd["gold"] = int(hd.get("gold", 0)) + int(dg) + hd["strength"] = int(hd.get("strength", 0)) + int(ds) + hd["magic"] = int(hd.get("magic", 0)) + int(dm) + hd["victory"] = int(hd.get("victory", 0)) + int(dv) + + def _apply_harvest_activation(self, player, starter_or_citizen, kind, on_turn): + """ + kind: "starter" | "citizen" + on_turn: use on-turn payout columns for the active player this harvest round. + """ + before_scores = self._player_scores_line(player) + card_name = getattr(starter_or_citizen, "name", "?") + turn_lbl = "on-turn" if on_turn else "off-turn" + def _special_cmd(obj, which): + """ + Some DB rows historically relied on a boolean has_special_payout_* flag. + In practice, the command text being present is sufficient, so treat + non-empty special_payout_* as the source of truth (ignoring "0"). + """ + raw = getattr(obj, which, None) + cmd = ("" if raw is None else str(raw)).strip() + if not cmd or cmd == "0": + return "" + return cmd + try: + if kind == "starter": + s = starter_or_citizen + if on_turn: + dg = int(getattr(s, "gold_payout_on_turn", 0) or 0) + ds = int(getattr(s, "strength_payout_on_turn", 0) or 0) + dm = int(getattr(s, "magic_payout_on_turn", 0) or 0) + player.gold_score = int(player.gold_score) + dg + player.strength_score = int(player.strength_score) + ds + player.magic_score = int(player.magic_score) + dm + self._bump_harvest_delta(player, dg, ds, dm, 0) + cmd = _special_cmd(s, "special_payout_on_turn") + if getattr(s, "has_special_payout_on_turn", False) or cmd: + payout = self.execute_special_payout(cmd or s.special_payout_on_turn, player.player_id) + player.gold_score = int(player.gold_score) + payout[0] + player.strength_score = int(player.strength_score) + payout[1] + player.magic_score = int(player.magic_score) + payout[2] + player.victory_score = int(player.victory_score) + payout[3] + self._bump_harvest_delta(player, payout[0], payout[1], payout[2], payout[3]) + else: + dg = int(getattr(s, "gold_payout_off_turn", 0) or 0) + ds = int(getattr(s, "strength_payout_off_turn", 0) or 0) + dm = int(getattr(s, "magic_payout_off_turn", 0) or 0) + player.gold_score = int(player.gold_score) + dg + player.strength_score = int(player.strength_score) + ds + player.magic_score = int(player.magic_score) + dm + self._bump_harvest_delta(player, dg, ds, dm, 0) + cmd = _special_cmd(s, "special_payout_off_turn") + if getattr(s, "has_special_payout_off_turn", False) or cmd: + payout = self.execute_special_payout(cmd or s.special_payout_off_turn, player.player_id) + player.gold_score = int(player.gold_score) + payout[0] + player.strength_score = int(player.strength_score) + payout[1] + player.magic_score = int(player.magic_score) + payout[2] + player.victory_score = int(player.victory_score) + payout[3] + self._bump_harvest_delta(player, payout[0], payout[1], payout[2], payout[3]) + return + + c = starter_or_citizen + if on_turn: + dg = int(getattr(c, "gold_payout_on_turn", 0) or 0) + ds = int(getattr(c, "strength_payout_on_turn", 0) or 0) + dm = int(getattr(c, "magic_payout_on_turn", 0) or 0) + player.gold_score = int(player.gold_score) + dg + player.strength_score = int(player.strength_score) + ds + player.magic_score = int(player.magic_score) + dm + self._bump_harvest_delta(player, dg, ds, dm, 0) + cmd = _special_cmd(c, "special_payout_on_turn") + if getattr(c, "has_special_payout_on_turn", False) or cmd: + payout = self.execute_special_payout(cmd or c.special_payout_on_turn, player.player_id) + player.gold_score = int(player.gold_score) + payout[0] + player.strength_score = int(player.strength_score) + payout[1] + player.magic_score = int(player.magic_score) + payout[2] + player.victory_score = int(player.victory_score) + payout[3] + self._bump_harvest_delta(player, payout[0], payout[1], payout[2], payout[3]) + else: + dg = int(getattr(c, "gold_payout_off_turn", 0) or 0) + ds = int(getattr(c, "strength_payout_off_turn", 0) or 0) + dm = int(getattr(c, "magic_payout_off_turn", 0) or 0) + player.gold_score = int(player.gold_score) + dg + player.strength_score = int(player.strength_score) + ds + player.magic_score = int(player.magic_score) + dm + self._bump_harvest_delta(player, dg, ds, dm, 0) + cmd = _special_cmd(c, "special_payout_off_turn") + if getattr(c, "has_special_payout_off_turn", False) or cmd: + payout = self.execute_special_payout(cmd or c.special_payout_off_turn, player.player_id) + player.gold_score = int(player.gold_score) + payout[0] + player.strength_score = int(player.strength_score) + payout[1] + player.magic_score = int(player.magic_score) + payout[2] + player.victory_score = int(player.victory_score) + payout[3] + self._bump_harvest_delta(player, payout[0], payout[1], payout[2], payout[3]) + finally: + after_scores = self._player_scores_line(player) + if before_scores != after_scores: + self._log_game_event( + f"{self._player_label(player.player_id)} harvest {kind} \"{card_name}\" " + f"({turn_lbl}): scores {before_scores} -> {after_scores}" + ) + + def _build_harvest_slots(self, player, consumed_keys, on_turn): + consumed = set(consumed_keys or []) + slots = [] + for idx, st in enumerate(getattr(player, "owned_starters", []) or []): + ok, n = self._roll_match_count(st) + if not ok: + continue + sid = int(getattr(st, "starter_id", -1)) + for i in range(n): + key = f"starter:{sid}:{idx}:{i}" + if key not in consumed: + slots.append({ + "slot_key": key, + "kind": "starter", + "card_id": sid, + "card_idx": idx, + "activation_index": i, + "name": getattr(st, "name", "?"), + "is_thief": False, + "_obj": st, + }) + for idx, cit in enumerate(getattr(player, "owned_citizens", []) or []): + ok, n = self._roll_match_count(cit) + if not ok: + continue + cid = int(getattr(cit, "citizen_id", -1)) + is_thief = _citizen_is_thief(cit) + for i in range(n): + key = f"citizen:{cid}:{idx}:{i}" + if key not in consumed: + slots.append({ + "slot_key": key, + "kind": "citizen", + "card_id": cid, + "card_idx": idx, + "activation_index": i, + "name": getattr(cit, "name", "?"), + "is_thief": is_thief, + "_obj": cit, + }) + return slots + + def _harvest_slots_sorted_for_simulation(self, slots): + starters = [s for s in slots if s["kind"] == "starter"] + citizens = [s for s in slots if s["kind"] == "citizen"] + thieves = [s for s in citizens if s["is_thief"]] + rest_c = [s for s in citizens if not s["is_thief"]] + return starters + thieves + rest_c + + def _player_has_unharvested_thief_citizen(self, player, consumed_keys): + consumed = set(consumed_keys or []) + for idx, cit in enumerate(getattr(player, "owned_citizens", []) or []): + if not _citizen_is_thief(cit): + continue + ok, n = self._roll_match_count(cit) + if not ok: + continue + cid = int(getattr(cit, "citizen_id", -1)) + for i in range(n): + key = f"citizen:{cid}:{idx}:{i}" + if key not in consumed: + return True + return False + + def _harvest_action_blocked(self): + if self.is_blocked_on_concurrent_action(): + return True + aid = self.action_required.get("id") if self.action_required else None + 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"): + return True + if str(aa).startswith("choose"): + return True + return False + + def _harvest_complete_finalize(self): + self.harvest_processed = True + self.harvest_player_order = None + self.harvest_player_idx = 0 + self.harvest_consumed = {} + self.pending_harvest_choices = [] + for p in self.player_list: + d = getattr(p, "harvest_delta", {}) or {} + if int(d.get("gold", 0)) == 0 and int(d.get("strength", 0)) == 0 and int(d.get("magic", 0)) == 0: + self.pending_harvest_choices.append(p.player_id) + if self.pending_harvest_choices: + self.action_required["id"] = self.pending_harvest_choices[0] + self.action_required["action"] = "bonus_resource_choice" + else: + self.action_required["id"] = self.game_id + self.action_required["action"] = "" + + def _harvest_run_automation_until_blocked(self): + while not getattr(self, "harvest_processed", False): + if self._harvest_action_blocked(): + return + order = getattr(self, "harvest_player_order", None) or [] + if self.harvest_player_idx >= len(order): + self._harvest_complete_finalize() + return + pid = order[self.harvest_player_idx] + player = self._player_by_id(pid) + if not player: + self.harvest_player_idx += 1 + continue + consumed_list = self.harvest_consumed.get(pid) + if consumed_list is None: + consumed_list = [] + self.harvest_consumed[pid] = consumed_list + on_turn = pid == self.current_player_id() + slots = self._build_harvest_slots(player, consumed_list, on_turn) + if not slots: + self.harvest_player_idx += 1 + continue + if len(slots) >= 2: + self.action_required["id"] = pid + self.action_required["action"] = "manual_harvest" + self._log_game_event( + f"{self._player_label(pid)}: choose harvest order ({len(slots)} matching cards)." + ) + return + slot = slots[0] + self._apply_harvest_activation(player, slot["_obj"], slot["kind"], on_turn) + consumed_list.append(slot["slot_key"]) + if self._harvest_action_blocked(): + return + + def harvest_slots_for_api(self): + if self.action_required.get("action") != "manual_harvest": + return [] + pid = self.action_required.get("id") + player = self._player_by_id(pid) + if not player: + return [] + consumed_list = self.harvest_consumed.get(pid) or [] + on_turn = pid == self.current_player_id() + slots = self._build_harvest_slots(player, consumed_list, on_turn) + out = [] + for s in slots: + out.append({ + "slot_key": s["slot_key"], + "kind": s["kind"], + "card_id": s["card_id"], + "card_idx": s.get("card_idx", 0), + "activation_index": s["activation_index"], + "name": s["name"], + "is_thief": s["is_thief"], + }) + return out + + def harvest_card(self, player_id, slot_key): + if self.phase != "harvest" or getattr(self, "harvest_processed", False): + raise ValueError("Not in harvest phase.") + if self.action_required.get("action") != "manual_harvest": + raise ValueError("No harvest choice is pending.") + if self.action_required.get("id") != player_id: + raise ValueError("It is not your turn to harvest.") + sk = (slot_key or "").strip() + if not sk: + raise ValueError("slot_key required.") + player = self._player_by_id(player_id) + if not player: + raise ValueError("Player not found.") + consumed_list = self.harvest_consumed.get(player_id) + if consumed_list is None: + consumed_list = [] + self.harvest_consumed[player_id] = consumed_list + on_turn = player_id == self.current_player_id() + slots = self._build_harvest_slots(player, consumed_list, on_turn) + chosen = None + for s in slots: + if s["slot_key"] == sk: + chosen = s + break + if not chosen: + raise ValueError("Invalid harvest slot.") + if chosen["kind"] == "citizen" and not chosen["is_thief"]: + if self._player_has_unharvested_thief_citizen(player, consumed_list): + raise ValueError("Harvest the Thief first.") + self._apply_harvest_activation(player, chosen["_obj"], chosen["kind"], on_turn) + consumed_list.append(sk) + # If the activation triggered a blocking prompt (e.g. special payout "choose ..."), + # do NOT clear action_required here. The player must respond first, and then + # act_on_required_action() will resume harvest automation. + aa = (self.action_required.get("action") or "").strip() + aid = self.action_required.get("id") + if aid == player_id and aa and aa != "manual_harvest": + return + + self.action_required["id"] = self.game_id + self.action_required["action"] = "" + self._harvest_run_automation_until_blocked() + if self.phase == "harvest" and self.harvest_processed and not self._harvest_action_blocked(): + self.advance_tick() def harvest_phase(self): - # steal activates first - for starter in self.player_list[0].owned_starters: - if (starter.roll_match1 == self.die_one) or (starter.roll_match1 == self.die_two) or ( - starter.roll_match1 == self.die_sum) or (starter.roll_match2 == self.die_sum): - count = 1 - if starter.roll_match1 == self.die_one == self.die_two: - count = 2 - print(f"Payout for {self.player_list[0].name}: Starter {starter.name}{' x2' if count == 2 else ''}") - for i in range(count): - self.player_list[0].gold_score = self.player_list[0].gold_score + starter.gold_payout_on_turn - self.player_list[0].strength_score = self.player_list[ - 0].strength_score + starter.strength_payout_on_turn - self.player_list[0].magic_score = self.player_list[0].magic_score + starter.magic_payout_on_turn - if starter.has_special_payout_on_turn: - payout = self.execute_special_payout(starter.special_payout_on_turn, - self.player_list[0].player_id) - self.player_list[0].gold_score = self.player_list[0].gold_score + payout[0] - self.player_list[0].strength_score = self.player_list[0].strength_score + payout[1] - self.player_list[0].magic_score = self.player_list[0].magic_score + payout[2] - for citizen in self.player_list[0].owned_citizens: - if (citizen.roll_match1 == self.die_one) or (citizen.roll_match1 == self.die_two) or ( - citizen.roll_match1 == self.die_sum) or (citizen.roll_match2 == self.die_sum): - count = 1 - if citizen.roll_match1 == self.die_one == self.die_two: - count = 2 - print(f"Payout for {self.player_list[0].name}: Citizen {citizen.name}{' x2' if count == 2 else ''}") - for i in range(count): - self.player_list[0].gold_score = self.player_list[0].gold_score + citizen.gold_payout_on_turn - self.player_list[0].strength_score = self.player_list[ - 0].strength_score + citizen.strength_payout_on_turn - self.player_list[0].magic_score = self.player_list[0].magic_score + citizen.magic_payout_on_turn - if citizen.has_special_payout_on_turn: - print(f"Citizen {citizen.name} special payout text: {citizen.special_payout_on_turn}") - payout = self.execute_special_payout(citizen.special_payout_on_turn, - self.player_list[0].player_id) - print(f"right after running execute special payout {payout}") - self.player_list[0].gold_score = self.player_list[0].gold_score + payout[0] - self.player_list[0].strength_score = self.player_list[0].strength_score + payout[1] - self.player_list[0].magic_score = self.player_list[0].magic_score + payout[2] - - list_iterator = iter(self.player_list) # skip first player when paying out the rest of the board - next(list_iterator) - for player in list_iterator: - for starter in player.owned_starters: - if (starter.roll_match1 == self.die_one) or (starter.roll_match1 == self.die_two) or ( - starter.roll_match1 == self.die_sum) or (starter.roll_match2 == self.die_sum): - count = 1 - if starter.roll_match1 == self.die_one == self.die_two: - count = 2 - print(f"Payout for {player.name}: Starter {starter.name}{' x2' if count == 2 else ''}") - for i in range(count): - player.gold_score = player.gold_score + starter.gold_payout_off_turn - player.strength_score = player.strength_score + starter.strength_payout_off_turn - player.magic_score = player.magic_score + starter.magic_payout_off_turn - if starter.has_special_payout_off_turn: - payout = self.execute_special_payout(starter.special_payout_off_turn, player.player_id) - player.gold_score = player.gold_score + payout[0] - player.strength_score = player.strength_score + payout[1] - player.magic_score = player.magic_score + payout[2] - - for citizen in player.owned_citizens: - if (citizen.roll_match1 == self.die_one) or (citizen.roll_match1 == self.die_two) or ( - citizen.roll_match1 == self.die_sum) or (citizen.roll_match2 == self.die_sum): - count = 1 - if citizen.roll_match1 == self.die_one == self.die_two: - count = 2 - print(f"Payout for {player.name}: Citizen {citizen.name}{' x2' if count == 2 else ''}") - for i in range(count): - player.gold_score = player.gold_score + citizen.gold_payout_off_turn - player.strength_score = player.strength_score + citizen.strength_payout_off_turn - player.magic_score = player.magic_score + citizen.magic_payout_off_turn - if citizen.has_special_payout_off_turn: - print("special payout off turn triggered") - print(citizen.special_payout_off_turn) - payout = self.execute_special_payout(citizen.special_payout_off_turn, player.player_id) - player.gold_score = player.gold_score + payout[0] - player.strength_score = player.strength_score + payout[1] - player.magic_score = player.magic_score + payout[2] + """Resolve the entire harvest non-interactively (local scripts / play_turn).""" + 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"]) 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)}, " f"Citizens: {len(player.owned_citizens)}, Domains {len(player.owned_domains)}") + def _maybe_resume_harvest_prompt(self): + if self.phase != "harvest" or getattr(self, "harvest_processed", False): + return + if getattr(self, "harvest_player_order", None) is None: + return + if self._harvest_action_blocked(): + return + self._harvest_run_automation_until_blocked() + def execute_special_payout(self, command, player_id): print("executing special payout") payout = [0, 0, 0, 0] # gp, sp, mp, vp, todo: citizen, monster, domain - split_command = command.split() + split_command = (command or "").split() + if not split_command: + payout[0] = -9999 + return payout + # Ensure safe indexing even for short commands. + split_command = split_command + ["", "", "", "", "", "", "", ""] first_word = split_command[0] second_word = split_command[1] third_word = split_command[2] @@ -194,18 +920,66 @@ class Game: case _: payout[0] = -9999 case "choose": - print("Matched choose") - self.action_required['id'] = player_id - self.action_required['action'] = command - # need to pause execution here until we get player input - print("pee") - input_thread = threading.Thread(target=self.wait_for_input, args=(split_command, player_id)) - input_thread.start() + # "choose ..." is a blocking prompt: no immediate payout is applied here. + # It is resolved later via act_on_required_action(), which applies the + # chosen payout and then resumes harvest automation (if active). + normalized, options = self._normalize_choose_command(command) + if not options: + payout[0] = -9999 + return payout + self.action_required["id"] = player_id + self.action_required["action"] = normalized + # Keep a small bit of context for debugging / future extensions. + self.pending_required_choice = { + "kind": "special_payout_choose", + "player_id": player_id, + "command": normalized, + "options": options, + } case _: payout[0] = -9999 print(payout) return payout + def _normalize_choose_command(self, command): + """ + Normalize a "choose" special payout into a canonical string + parsed options. + + Supported input formats (1-3 options): + - "choose g 2 m 2" + - "choose g 1 s 1 m 1" + Returns: + - (normalized_command: str, options: list[dict{token, amount}]) + """ + parts = (command or "").strip().split() + if not parts or parts[0].lower() != "choose": + return (command or ""), [] + rest = parts[1:] + options = [] + + def add_opt(tok, amt): + t = (tok or "").strip().lower() + if t not in ("g", "s", "m", "v"): + return + try: + n = int(amt) + except (TypeError, ValueError): + return + options.append({"token": t, "amount": n}) + + # Strict: pairs token, amount (g 2 m 2 ...) + i = 0 + while i + 1 < len(rest) and len(options) < 3: + a, b = rest[i], rest[i + 1] + if (a or "").lower() in ("g", "s", "m", "v"): + add_opt(a, b) + i += 2 + continue + break + + normalized = "choose " + " ".join([f"{o['token']} {o['amount']}" for o in options]) if options else (command or "") + return normalized, options + def owned_monster_attributes(self, player_id): return_dict = {attr: 0 for attr in Constants.areas + Constants.types} for player in self.player_list: @@ -253,16 +1027,156 @@ class Game: player.strength_score = player.strength_score + payout[1] player.magic_score = player.magic_score + payout[2] player.victory_score = player.victory_score + payout[3] + # If this payout is resolving a harvest-time choice, track it on the same harvest delta. + if not hasattr(player, "harvest_delta") or not isinstance(player.harvest_delta, dict): + player.harvest_delta = {"gold": 0, "strength": 0, "magic": 0, "victory": 0} + player.harvest_delta["gold"] = int(player.harvest_delta.get("gold", 0)) + int(payout[0]) + player.harvest_delta["strength"] = int(player.harvest_delta.get("strength", 0)) + int(payout[1]) + player.harvest_delta["magic"] = int(player.harvest_delta.get("magic", 0)) + int(payout[2]) + player.harvest_delta["victory"] = int(player.harvest_delta.get("victory", 0)) + int(payout[3]) 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)}, " f"Citizens: {len(player.owned_citizens)}, Domains {len(player.owned_domains)}") + self._maybe_resume_harvest_prompt() def act_on_required_action(self, player_id, action): if self.action_required['id'] == player_id: print("correct player responded to action") - self.action_required['action'] = action - self.action_required['id'] = self.game_id + current_required = self.action_required.get("action", "") + + # Special: bonus resource choice (imaginary starter on "no payout" harvest) + if current_required == "bonus_resource_choice": + choice = (action or "").strip().lower() + if choice not in ("gold", "strength", "magic"): + return + target = self._player_by_id(player_id) + if not target: + return + before = self._player_scores_line(target) + if choice == "gold": + target.gold_score += 1 + target.harvest_delta["gold"] = int(target.harvest_delta.get("gold", 0)) + 1 + elif choice == "strength": + target.strength_score += 1 + target.harvest_delta["strength"] = int(target.harvest_delta.get("strength", 0)) + 1 + else: + target.magic_score += 1 + target.harvest_delta["magic"] = int(target.harvest_delta.get("magic", 0)) + 1 + after = self._player_scores_line(target) + self._log_game_event( + f"{self._player_label(player_id)} harvest bonus +1 {choice} (no gold/strength/magic spent); " + f"scores {before} -> {after}" + ) + + # Pop current pending player and either queue the next, or clear blocking. + if getattr(self, "pending_harvest_choices", None): + if self.pending_harvest_choices and self.pending_harvest_choices[0] == player_id: + self.pending_harvest_choices.pop(0) + if getattr(self, "pending_harvest_choices", None) and self.pending_harvest_choices: + self.action_required["id"] = self.pending_harvest_choices[0] + self.action_required["action"] = "bonus_resource_choice" + return + + self.action_required['action'] = "" + self.action_required['id'] = self.game_id + return + + # Resolve a blocking "choose ..." special payout prompt. + if str(current_required).strip().lower().startswith("choose"): + normalized, options = self._normalize_choose_command(current_required) + if not options: + return + act = (action or "").strip().lower() + if not act.startswith("choose "): + return + try: + idx = int(act.split()[1]) - 1 + except (IndexError, ValueError): + return + if idx < 0 or idx >= len(options): + return + opt = options[idx] + target = self._player_by_id(player_id) + if not target: + return + before = self._player_scores_line(target) + dg = ds = dm = dv = 0 + if opt["token"] == "g": + dg = opt["amount"] + elif opt["token"] == "s": + ds = opt["amount"] + elif opt["token"] == "m": + dm = opt["amount"] + else: + dv = opt["amount"] + target.gold_score = int(target.gold_score) + int(dg) + target.strength_score = int(target.strength_score) + int(ds) + target.magic_score = int(target.magic_score) + int(dm) + target.victory_score = int(getattr(target, "victory_score", 0)) + int(dv) + if not hasattr(target, "harvest_delta") or not isinstance(target.harvest_delta, dict): + target.harvest_delta = {"gold": 0, "strength": 0, "magic": 0, "victory": 0} + self._bump_harvest_delta(target, dg, ds, dm, dv) + after = self._player_scores_line(target) + self._log_game_event( + f"{self._player_label(player_id)} chose ({idx + 1}/{len(options)}) from \"{normalized}\": " + f"{opt['token']} {opt['amount']}; scores {before} -> {after}" + ) + # Clear the prompt, then resume harvest automation if applicable. + self.action_required["action"] = "" + self.action_required["id"] = self.game_id + if getattr(self, "pending_required_choice", None): + self.pending_required_choice = None + self._maybe_resume_harvest_prompt() + return + + self.action_required["action"] = action + self.action_required["id"] = self.game_id + + def submit_concurrent_action(self, player_id, response, kind=None): + """ + Record one player's response to the active concurrent action. + + - `kind`, if provided, must match the active concurrent_action.kind + (sanity check against stale clients). + - The handler's apply() runs immediately for this player; if it raises + ValueError the response is rejected and the player remains pending. + - When the last pending player responds, the handler's finalize() runs + and concurrent_action is cleared. If the game was sitting in setup, + we advance the engine so it lands on the next actionable phase. + """ + ca = getattr(self, "concurrent_action", None) or None + if not ca: + raise ValueError("No concurrent action is pending.") + if kind and kind != ca.get("kind"): + raise ValueError( + f"Concurrent action kind mismatch (expected {ca.get('kind')!r}, got {kind!r})." + ) + pending = ca.get("pending") or [] + if player_id not in pending: + raise ValueError("You have no pending response in this concurrent action.") + handler = CONCURRENT_HANDLERS.get(ca.get("kind")) + if not handler: + raise ValueError(f"Unknown concurrent action kind: {ca.get('kind')!r}.") + + handler.apply(self, player_id, response) + self._log_game_event( + f"{self._player_label(player_id)} submitted ({ca.get('kind')})." + ) + ca.setdefault("responses", {})[player_id] = response + ca["pending"] = [pid for pid in pending if pid != player_id] + ca.setdefault("completed", []).append(player_id) + + if not ca["pending"]: + 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. + if self.phase == "setup": + while self.advance_tick(): + if self.phase == "action": + break def update_payout_for_role(self, role_name, player_id, payout, split_command): role_count = 0 @@ -285,54 +1199,181 @@ class Game: else: payout[0] = -9999 - def hire_citizen(self, player_id, citizen_id, gp, mp=0): + def hire_citizen(self, player_id, citizen_id, gp=0, mp=0, sp=0): + """ + Hire the top/accessible citizen from a stack. + + Gold cost scales by +1 for each already-owned card with the same name, + counting both owned citizens and starting cards. + + Payment is (gold, magic, strength); only gold and magic may be used (strength must be 0). + """ + gp, sp, mp = _n(gp), _n(sp), _n(mp) + for citizen_stack in self.citizen_grid: - for citizen in citizen_stack: - if citizen.citizen_id == citizen_id and citizen.is_accessible: - for player in self.player_list: - if player.player_id == player_id: - player.gold_score = player.gold_score - gp - player.magic_score = player.magic_score - mp - player.owned_citizens.append(citizen_stack.pop(-1)) - citizen_stack[-1].toggle_accessibility(True) + if not citizen_stack: + continue + top = citizen_stack[-1] + if int(getattr(top, "citizen_id", -1)) != int(citizen_id) or not getattr(top, "is_accessible", False): + continue - def slay_monster(self, player_id, monster_id, sp, mp=0): + player = None + for p in self.player_list: + if p.player_id == player_id: + player = p + break + if not player: + raise ValueError("Player not found.") + + owned_same_name = 0 + for c in getattr(player, "owned_citizens", []) or []: + if getattr(c, "name", None) == top.name: + owned_same_name += 1 + for s in getattr(player, "owned_starters", []) or []: + if getattr(s, "name", None) == top.name: + owned_same_name += 1 + + scaled_cost = int(getattr(top, "gold_cost", 0) or 0) + int(owned_same_name) + _validate_hire_or_domain_gold_payment(player, scaled_cost, gp, sp, mp) + + before = self._player_scores_line(player) + player.gold_score = player.gold_score - gp + player.magic_score = player.magic_score - mp + player.owned_citizens.append(citizen_stack.pop(-1)) + + if citizen_stack: + citizen_stack[-1].toggle_accessibility(True) + after = self._player_scores_line(player) + pay = self._format_resource_payment(gp, sp, mp) + self._log_game_event( + f"{self._player_label(player_id)} hired citizen \"{top.name}\" ({pay}); scores {before} -> {after}" + ) + return + + raise ValueError("Citizen not available to hire.") + + def slay_monster(self, player_id, monster_id, sp=0, mp=0, gp=0): + gp, sp, mp = _n(gp), _n(sp), _n(mp) payout = [0, 0, 0, 0] - for monster_stack in self.monster_grid: - for monster in monster_stack: - if monster.monster_id == monster_id: # and monster.is_accessible: - monster_to_add = monster_stack[-1] - monster_stack.pop(-1) - for player in self.player_list: - if player.player_id == player_id: - player.strength_score = player.strength_score - sp - player.magic_score = player.magic_score - mp - player.owned_monsters.append(monster_to_add) - if monster.has_special_reward: - payout = self.execute_special_payout(monster.special_reward, player_id) - payout[0] = payout[0] + monster.gold_reward - payout[1] = payout[1] + monster.strength_reward - payout[2] = payout[2] + monster.magic_reward - payout[3] = payout[3] + monster.vp_reward - for player in self.player_list: - if player.player_id == player_id: - player.gold_score = player.gold_score + payout[0] - player.strength_score = player.strength_score + payout[1] - player.magic_score = player.magic_score + payout[2] - player.victory_score = player.victory_score + payout[3] - player.owned_monster_attributes = self.owned_monster_attributes(player_id) - monster_stack[-1].toggle_accessibility(True) - def buy_domain(self, player_id, domain_id, gp, mp=0): + for monster_stack in self.monster_grid: + if not monster_stack: + continue + top = monster_stack[-1] + if int(getattr(top, "monster_id", -1)) != int(monster_id): + continue + if not getattr(top, "is_accessible", False): + continue + + player = None + for p in self.player_list: + if p.player_id == player_id: + player = p + break + if not player: + raise ValueError("Player not found.") + + _validate_monster_slay_payment(player, top.strength_cost, top.magic_cost, gp, sp, mp) + + before = self._player_scores_line(player) + monster_to_add = monster_stack.pop(-1) + player.strength_score = player.strength_score - sp + player.magic_score = player.magic_score - mp + player.owned_monsters.append(monster_to_add) + + if top.has_special_reward: + payout = self.execute_special_payout(top.special_reward, player_id) + payout[0] = payout[0] + top.gold_reward + payout[1] = payout[1] + top.strength_reward + payout[2] = payout[2] + top.magic_reward + payout[3] = payout[3] + top.vp_reward + player.gold_score = player.gold_score + payout[0] + player.strength_score = player.strength_score + payout[1] + player.magic_score = player.magic_score + payout[2] + player.victory_score = player.victory_score + payout[3] + player.owned_monster_attributes = self.owned_monster_attributes(player_id) + + if monster_stack: + monster_stack[-1].toggle_accessibility(True) + after = self._player_scores_line(player) + pay = self._format_resource_payment(gp, sp, mp) + self._log_game_event( + f"{self._player_label(player_id)} slew monster \"{monster_to_add.name}\" ({pay}); scores {before} -> {after}" + ) + return + + raise ValueError("Monster not available to slay.") + + def buy_domain(self, player_id, domain_id, gp=0, mp=0, sp=0): + gp, sp, mp = _n(gp), _n(sp), _n(mp) + for domain_stack in self.domain_grid: - for domain in domain_stack: - if domain.domain_id == domain_id and domain.is_accessible: - for player in self.player_list: - if player.player_id == player_id: - player.gold_score = player.gold_score - gp - player.magic_score = player.magic_score - mp - player.owned_domains.append(domain_stack.pop(-1)) - domain_stack[-1].toggle_accessibility(True) + if not domain_stack: + continue + top = domain_stack[-1] + if int(getattr(top, "domain_id", -1)) != int(domain_id): + continue + if not getattr(top, "is_accessible", False): + continue + if not getattr(top, "is_visible", True): + continue + + player = None + for p in self.player_list: + if p.player_id == player_id: + player = p + break + if not player: + raise ValueError("Player not found.") + + gold_cost = int(getattr(top, "gold_cost", 0) or 0) + _validate_hire_or_domain_gold_payment(player, gold_cost, gp, sp, mp) + + before = self._player_scores_line(player) + player.gold_score = player.gold_score - gp + player.magic_score = player.magic_score - mp + player.owned_domains.append(domain_stack.pop(-1)) + + if domain_stack: + domain_stack[-1].toggle_accessibility(True) + after = self._player_scores_line(player) + pay = self._format_resource_payment(gp, sp, mp) + self._log_game_event( + f"{self._player_label(player_id)} bought domain \"{top.name}\" ({pay}); scores {before} -> {after}" + ) + return + + raise ValueError("Domain not available to purchase.") + + def take_resource(self, player_id, resource): + """ + Spend a standard action to gain +1 gold, strength, or magic (player's choice). + """ + choice = (resource or "").strip().lower() + if choice not in ("gold", "strength", "magic"): + raise ValueError('resource must be "gold", "strength", or "magic".') + + player = None + for p in self.player_list: + if p.player_id == player_id: + player = p + break + if not player: + raise ValueError("Player not found.") + + before = self._player_scores_line(player) + if choice == "gold": + player.gold_score = int(getattr(player, "gold_score", 0)) + 1 + elif choice == "strength": + player.strength_score = int(getattr(player, "strength_score", 0)) + 1 + else: + player.magic_score = int(getattr(player, "magic_score", 0)) + 1 + + after = self._player_scores_line(player) + self._log_game_event( + f"{self._player_label(player_id)} took +1 {choice} (standard action; no gold/strength/magic cost); " + f"scores {before} -> {after}" + ) def action_phase(self): return @@ -373,6 +1414,7 @@ class Player: "harvest_phase": [], "action_phase": [] } + self.harvest_delta = {"gold": 0, "strength": 0, "magic": 0, "victory": 0} @classmethod def from_dict(cls, data): @@ -389,11 +1431,13 @@ class Player: player.magic_score = data['magic_score'] player.victory_score = data['victory_score'] player.is_first = data['is_first'] - player.shadow_count = data['shadow_count'] - player.holy_count = data['holy_count'] - player.soldier_count = data['soldier_count'] - player.worker_count = data['worker_count'] player.effects = data['effects'] + player.harvest_delta = data.get('harvest_delta', {"gold": 0, "strength": 0, "magic": 0, "victory": 0}) + roles = player.calc_roles() + player.shadow_count = roles['shadow_count'] + player.holy_count = roles['holy_count'] + player.soldier_count = roles['soldier_count'] + player.worker_count = roles['worker_count'] return player def calc_roles(self): @@ -463,6 +1507,14 @@ def load_game_data(game_id, preset, player_list_from_lobby): "id": "", "action": "" } + tick_id = 0 + turn_number = 1 + turn_index = 0 + # Start in setup; if no setup actions are needed the engine will advance into roll. + phase = 'setup' + actions_remaining = 0 + harvest_processed = False + pending_harvest_choices = [] match preset: case "base1": monster_query = "select_base1_monsters" @@ -472,7 +1524,7 @@ def load_game_data(game_id, preset, player_list_from_lobby): citizen_query = "select_base2_citizens" try: my_connect = mariadb.connect(user='vckonline', password='vckonline', host='127.0.0.1', - database='vckonline') + database='vckonline', port=3306) my_cursor = my_connect.cursor(dictionary=True) my_cursor.callproc(monster_query) @@ -615,7 +1667,19 @@ def load_game_data(game_id, preset, player_list_from_lobby): 'die_sum': die_sum, 'exhausted_count': exhausted_count, 'effects': effects, - 'action_required': action_required} + 'action_required': action_required, + 'concurrent_action': None, + 'tick_id': tick_id, + 'turn_number': turn_number, + 'turn_index': turn_index, + 'phase': phase, + 'actions_remaining': actions_remaining, + 'harvest_processed': harvest_processed, + 'pending_harvest_choices': pending_harvest_choices, + 'harvest_player_order': None, + 'harvest_player_idx': 0, + 'harvest_consumed': {}, + 'game_log': []} # Return the dictionary return game_state @@ -658,24 +1722,28 @@ class SummaryEncoder(JSONEncoder): class GameObjectEncoder(JSONEncoder): def default(self, obj): if isinstance(obj, Player): + # Role totals come from owned citizens + domains (see calc_roles); keep JSON aligned with gameplay. + roles = obj.calc_roles() return { 'player_id': obj.player_id, 'name': obj.name, - 'owned_starters': [starter.starter_id for starter in obj.owned_starters], - 'owned_citizens': [citizen.citizen_id for citizen in obj.owned_citizens], - 'owned_domains': [domain.domain_id for domain in obj.owned_domains], - 'owned_dukes': [duke.duke_id for duke in obj.owned_dukes], - 'owned_monsters': [monster.monster_id for monster in obj.owned_monsters], + # Dev client wants to render a tableau; include full objects (not just ids). + 'owned_starters': [starter.to_dict() for starter in obj.owned_starters], + 'owned_citizens': [citizen.to_dict() for citizen in obj.owned_citizens], + 'owned_domains': [domain.to_dict() for domain in obj.owned_domains], + 'owned_dukes': [duke.to_dict() for duke in obj.owned_dukes], + 'owned_monsters': [monster.to_dict() for monster in obj.owned_monsters], 'gold_score': obj.gold_score, 'strength_score': obj.strength_score, 'magic_score': obj.magic_score, 'victory_score': obj.victory_score, 'is_first': obj.is_first, - 'shadow_count': obj.shadow_count, - 'holy_count': obj.holy_count, - 'soldier_count': obj.soldier_count, - 'worker_count': obj.worker_count, - 'effects': obj.effects + 'shadow_count': roles['shadow_count'], + 'holy_count': roles['holy_count'], + 'soldier_count': roles['soldier_count'], + 'worker_count': roles['worker_count'], + 'effects': obj.effects, + 'harvest_delta': getattr(obj, "harvest_delta", {"gold": 0, "strength": 0, "magic": 0, "victory": 0}) } elif isinstance(obj, Duke): return obj.to_dict() @@ -688,7 +1756,7 @@ class GameObjectEncoder(JSONEncoder): elif isinstance(obj, Domain): return obj.to_dict() elif isinstance(obj, Game): - return { + base = { "game_id": obj.game_id, "player_list": obj.player_list, "monster_grid": obj.monster_grid, @@ -697,9 +1765,26 @@ class GameObjectEncoder(JSONEncoder): "die_one": obj.die_one, "die_two": obj.die_two, "die_sum": obj.die_sum, + "rolled_die_one": getattr(obj, "rolled_die_one", obj.die_one), + "rolled_die_two": getattr(obj, "rolled_die_two", obj.die_two), + "rolled_die_sum": getattr(obj, "rolled_die_sum", obj.die_sum), + "pending_roll": getattr(obj, "pending_roll", None), "exhausted_count": obj.exhausted_count, "effects": obj.effects, - "action_required": obj.action_required + "action_required": obj.action_required, + "concurrent_action": getattr(obj, "concurrent_action", None), + "tick_id": getattr(obj, "tick_id", 0), + "turn_number": getattr(obj, "turn_number", 1), + "turn_index": getattr(obj, "turn_index", 0), + "phase": getattr(obj, "phase", "roll"), + "actions_remaining": getattr(obj, "actions_remaining", 0), + "active_player_id": obj.current_player_id() if hasattr(obj, "current_player_id") else None, + "harvest_player_order": getattr(obj, "harvest_player_order", None), + "harvest_player_idx": getattr(obj, "harvest_player_idx", 0), + "harvest_consumed": getattr(obj, "harvest_consumed", {}) or {}, + "harvest_prompt_slots": obj.harvest_slots_for_api() if hasattr(obj, "harvest_slots_for_api") else [], + "game_log": list(getattr(obj, "game_log", None) or []), } + return base else: return super().default(obj) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b505ee5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,22 @@ +# Core database connection +# NOTE: mariadb package requires MariaDB Connector/C to be installed first +# +# Installation: +# 1. Install MariaDB Connector/C: +# macOS: brew install mariadb-connector-c +# Linux: sudo apt-get install libmariadb-dev (Debian/Ubuntu) or equivalent +# +# 2. Set MARIADB_CONFIG environment variable before pip install: +# export MARIADB_CONFIG="/opt/homebrew/Cellar/mariadb-connector-c/3.4.8/bin/mariadb_config" +# (or run: ./setup_venv.sh which does this automatically) +# +# 3. Then install: pip install -r requirements.txt +mariadb>=1.1.0 + +# UUID generation +shortuuid>=1.0.0 + +# Web framework for API server +fastapi>=0.104.0 +uvicorn>=0.24.0 + diff --git a/server.py b/server.py new file mode 100644 index 0000000..ef6a530 --- /dev/null +++ b/server.py @@ -0,0 +1,2382 @@ +#!/usr/bin/env python3 +""" +FastAPI server for VCK Online - Development/testing server +Simple REST API to replace the socket-based protocol +""" + +import time +import uuid +from typing import Dict, List, Optional +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import HTMLResponse +from pydantic import BaseModel +import shortuuid + +from game import Game, LobbyMember, GameMember, load_game_data, GameObjectEncoder +import json + +app = FastAPI(title="VCK Online API", description="Development server for Valeria Card Kingdoms Online") + +# CORS middleware for web client +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# In-memory storage (simple for dev) +lobby: List[LobbyMember] = [] +games: Dict[str, Game] = {} +gamers: List[GameMember] = [] + + +# Request/Response models +class JoinLobbyRequest(BaseModel): + name: str + + +class RenameRequest(BaseModel): + player_id: str + name: str + + +class ReadyRequest(BaseModel): + player_id: str + + +class ResourcePayment(BaseModel): + """How much gold / strength / magic the client spends on an action (validated server-side).""" + gold: int = 0 + strength: int = 0 + magic: int = 0 + + +class GameActionRequest(BaseModel): + player_id: str + action_type: str # "hire_citizen", "buy_domain", "slay_monster", "take_resource", "act_on_required_action", "submit_concurrent_action" + # Action parameters (varies by action type) + citizen_id: Optional[int] = None + domain_id: Optional[int] = None + monster_id: Optional[int] = None + # take_resource: "gold" | "strength" | "magic" + resource: Optional[str] = None + gold_cost: Optional[int] = None + strength_cost: Optional[int] = None + magic_cost: Optional[int] = None + # Preferred: explicit payment split (gold/strength/magic). If set, overrides legacy *_cost fields for that action. + payment: Optional[ResourcePayment] = None + action: Optional[str] = None # For act_on_required_action + harvest_slot_key: Optional[str] = None # harvest_card: e.g. "citizen:3:0" + # submit_concurrent_action: which non-ordered prompt this responds to, + # plus the opaque per-player payload (string; handler decides how to parse). + kind: Optional[str] = None + response: Optional[str] = None + # finalize_roll: optional override dice values (1-6). If omitted, server finalizes using the rolled dice. + die_one: Optional[int] = None + die_two: Optional[int] = None + + +def _rollback_consumed_action(game): + game.actions_remaining = int(getattr(game, "actions_remaining", 0)) + 1 + game.tick_id = int(getattr(game, "tick_id", 0)) - 1 + + +def resolve_action_payment(req: GameActionRequest): + if req.payment is not None: + return int(req.payment.gold or 0), int(req.payment.strength or 0), int(req.payment.magic or 0) + if req.action_type == "slay_monster": + return 0, int(req.strength_cost or 0), int(req.magic_cost or 0) + if req.action_type == "hire_citizen": + return int(req.gold_cost or 0), 0, int(req.magic_cost or 0) + if req.action_type == "buy_domain": + return int(req.gold_cost or 0), 0, int(req.magic_cost or 0) + return int(req.gold_cost or 0), int(req.strength_cost or 0), int(req.magic_cost or 0) + + +# Lobby endpoints +@app.post("/api/lobby/join") +async def join_lobby(request: JoinLobbyRequest): + """Join the lobby with a player name""" + player_id = str(shortuuid.uuid()) + player = LobbyMember(request.name, player_id) + player.last_active_time = time.time() + lobby.append(player) + return {"player_id": player_id, "message": "Joined lobby"} + + +@app.post("/api/lobby/rename") +async def rename_player(request: RenameRequest): + """Rename a player in the lobby""" + for player in lobby: + if player.player_id == request.player_id: + player.name = request.name + player.last_active_time = time.time() + return {"message": "Player renamed"} + raise HTTPException(status_code=404, detail="Player not found in lobby") + + +@app.post("/api/lobby/leave") +async def leave_lobby(player_id: str): + """Leave the lobby""" + global lobby + lobby = [p for p in lobby if p.player_id != player_id] + return {"message": "Left lobby"} + + +@app.post("/api/lobby/ready") +async def set_ready(request: ReadyRequest): + """Mark player as ready""" + for player in lobby: + if player.player_id == request.player_id: + player.is_ready = True + player.last_active_time = time.time() + + # Check if all players are ready + ready_count = sum(1 for p in lobby if p.is_ready) + if ready_count == len(lobby) and len(lobby) >= 2: + # Start game + new_game_id = str(uuid.uuid4()) + players_to_remove = [] + + for p in lobby: + if p.is_ready: + gamer = GameMember(p.player_id, p.name, new_game_id) + gamers.append(gamer) + players_to_remove.append(p) + + # Remove ready players from lobby + for p in players_to_remove: + lobby.remove(p) + + # Create game + try: + # Get only the gamers for this new game + game_gamers = [g for g in gamers if g.game_id == new_game_id] + game_state = load_game_data(new_game_id, "base1", game_gamers) + new_game = Game(game_state) + new_game.last_active_time = time.time() + # Auto-run the start-of-game roll/harvest so the first state is actionable. + while new_game.advance_tick(): + if getattr(new_game, "phase", None) == "action": + break + games[new_game_id] = new_game + return { + "message": "Game started", + "game_id": new_game_id, + "players": [{"player_id": g.player_id, "name": g.name} for g in game_gamers] + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to create game: {str(e)}") + + return {"message": "Player ready", "all_ready": ready_count == len(lobby)} + + raise HTTPException(status_code=404, detail="Player not found in lobby") + + +@app.post("/api/lobby/unready") +async def set_unready(request: ReadyRequest): + """Mark player as not ready""" + for player in lobby: + if player.player_id == request.player_id: + player.is_ready = False + player.last_active_time = time.time() + return {"message": "Player unready"} + raise HTTPException(status_code=404, detail="Player not found in lobby") + + +@app.get("/api/lobby/status") +async def get_lobby_status(player_id: Optional[str] = None): + """Get lobby status. If player_id provided, also check if player is in a game.""" + # Clean up inactive players (60 seconds) + current_time = time.time() + global lobby + lobby = [p for p in lobby if current_time - p.last_active_time <= 60] + + lobby_data = [] + for member in lobby: + lobby_data.append({ + "player_id": member.player_id, + "name": member.name, + "is_ready": member.is_ready + }) + + response = { + "lobby": lobby_data, + "game_count": len(games) + } + + # Check if player is in a game + if player_id: + for gamer in gamers: + if gamer.player_id == player_id: + response["in_game"] = True + response["game_id"] = gamer.game_id + return response + + response["in_game"] = False + return response + + +# Game endpoints +def _serialize_game_for_player(game, viewer_player_id: Optional[str]): + """ + Serialize the game state for a given viewer. + + Security/hidden-info rule: + - Dukes are hidden information. Only the viewing player should see their own duke. + """ + game_json = json.dumps(game, cls=GameObjectEncoder, indent=2) + state = json.loads(game_json) + + players = state.get("player_list") or [] + if not isinstance(players, list): + return state + + for p in players: + if not isinstance(p, dict): + continue + pid = p.get("player_id") + if not viewer_player_id or pid != viewer_player_id: + # Hide opponent (and spectator) dukes. + p["owned_dukes"] = [] + + return state + + +@app.get("/api/game/{game_id}/state") +async def get_game_state(game_id: str, player_id: Optional[str] = None): + """Get the current game state""" + game = games.get(game_id) + if not game: + raise HTTPException(status_code=404, detail="Game not found") + + game.last_active_time = time.time() + # Ensure the beginning-of-turn roll/harvest are automatic (including the very first fetch). + while getattr(game, "phase", None) in ("roll", "harvest"): + if not game.advance_tick(): + break + if getattr(game, "phase", None) == "action": + break + return _serialize_game_for_player(game, player_id) + + +@app.post("/api/game/{game_id}/action") +async def perform_game_action(game_id: str, request: GameActionRequest): + """Perform a game action (hire citizen, buy domain, slay monster, etc.)""" + game = games.get(game_id) + if not game: + raise HTTPException(status_code=404, detail="Game not found") + + game.last_active_time = time.time() + + try: + if request.action_type == "hire_citizen": + if request.citizen_id is None: + raise HTTPException(status_code=400, detail="citizen_id required") + if request.payment is None and request.gold_cost is None and request.magic_cost is None: + raise HTTPException(status_code=400, detail="payment or gold_cost/magic_cost required") + if not game.consume_player_action(request.player_id): + raise HTTPException(status_code=400, detail="Not your turn (or no actions remaining)") + g, s, m = resolve_action_payment(request) + try: + game.hire_citizen(request.player_id, request.citizen_id, g, m, s) + except ValueError as e: + _rollback_consumed_action(game) + raise HTTPException(status_code=400, detail=str(e)) + except Exception: + _rollback_consumed_action(game) + raise + game.finish_turn_if_no_actions_remaining() + + elif request.action_type == "buy_domain": + if request.domain_id is None: + raise HTTPException(status_code=400, detail="domain_id required") + if request.payment is None and request.gold_cost is None and request.magic_cost is None: + raise HTTPException(status_code=400, detail="payment or gold_cost/magic_cost required") + if not game.consume_player_action(request.player_id): + raise HTTPException(status_code=400, detail="Not your turn (or no actions remaining)") + g, s, m = resolve_action_payment(request) + try: + game.buy_domain(request.player_id, request.domain_id, g, m, s) + except ValueError as e: + _rollback_consumed_action(game) + raise HTTPException(status_code=400, detail=str(e)) + except Exception: + _rollback_consumed_action(game) + raise + game.finish_turn_if_no_actions_remaining() + + elif request.action_type == "slay_monster": + if request.monster_id is None: + raise HTTPException(status_code=400, detail="monster_id required") + if request.payment is None and request.strength_cost is None and request.magic_cost is None: + raise HTTPException(status_code=400, detail="payment or strength_cost/magic_cost required") + if not game.consume_player_action(request.player_id): + raise HTTPException(status_code=400, detail="Not your turn (or no actions remaining)") + g, s, m = resolve_action_payment(request) + try: + game.slay_monster(request.player_id, request.monster_id, s, m, g) + except ValueError as e: + _rollback_consumed_action(game) + raise HTTPException(status_code=400, detail=str(e)) + except Exception: + _rollback_consumed_action(game) + raise + game.finish_turn_if_no_actions_remaining() + + elif request.action_type == "take_resource": + if request.resource is None or not str(request.resource).strip(): + raise HTTPException(status_code=400, detail='resource required ("gold", "strength", or "magic")') + r = str(request.resource).strip().lower() + if r not in ("gold", "strength", "magic"): + raise HTTPException(status_code=400, detail='resource must be "gold", "strength", or "magic"') + if not game.consume_player_action(request.player_id): + raise HTTPException(status_code=400, detail="Not your turn (or no actions remaining)") + try: + game.take_resource(request.player_id, r) + except ValueError as e: + _rollback_consumed_action(game) + raise HTTPException(status_code=400, detail=str(e)) + except Exception: + _rollback_consumed_action(game) + raise + game.finish_turn_if_no_actions_remaining() + + elif request.action_type == "act_on_required_action": + if request.action is None: + raise HTTPException(status_code=400, detail="action required") + game.act_on_required_action(request.player_id, request.action) + # resolving a required action may unblock the engine + game.advance_tick() + + elif request.action_type == "submit_concurrent_action": + if request.response is None: + raise HTTPException(status_code=400, detail="response required") + try: + game.submit_concurrent_action( + request.player_id, + request.response, + kind=request.kind, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + elif request.action_type == "finalize_roll": + try: + game.finalize_roll(request.player_id, die_one=request.die_one, die_two=request.die_two) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + # After finalizing, auto-advance harvest as far as possible. + while getattr(game, "phase", None) == "harvest": + if not game.advance_tick(): + break + if getattr(game, "phase", None) == "action": + break + + elif request.action_type == "roll_phase": + raise HTTPException(status_code=400, detail="roll_phase is automatic; reserved for future reroll effects") + + elif request.action_type == "harvest_phase": + raise HTTPException(status_code=400, detail="harvest_phase is automatic") + + elif request.action_type == "harvest_card": + if not request.harvest_slot_key or not str(request.harvest_slot_key).strip(): + raise HTTPException(status_code=400, detail="harvest_slot_key required") + try: + game.harvest_card(request.player_id, str(request.harvest_slot_key).strip()) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + elif request.action_type == "play_turn": + # Advance through roll+harvest, then leave at action phase + # (player actions will drive action ticks) + while game.advance_tick(): + if game.phase == "action": + break + + else: + raise HTTPException(status_code=400, detail=f"Unknown action type: {request.action_type}") + + # Return updated game state + return {"message": "Action performed", "game_state": _serialize_game_for_player(game, request.player_id)} + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Action failed: {str(e)}") + + +# Cleanup inactive games (runs periodically) +@app.on_event("startup") +async def startup_event(): + """Cleanup task for inactive games""" + import asyncio + + async def cleanup(): + while True: + await asyncio.sleep(30) # Check every 30 seconds + current_time = time.time() + inactive_games = [ + game_id for game_id, game in games.items() + if current_time - game.last_active_time > 180 + ] + for game_id in inactive_games: + del games[game_id] + # Remove gamers from this game + global gamers + gamers = [g for g in gamers if g.game_id != game_id] + + asyncio.create_task(cleanup()) + + +# Serve static files and simple HTML client +try: + app.mount("/static", StaticFiles(directory="static"), name="static") +except: + pass # static directory might not exist + + +@app.get("/", response_class=HTMLResponse) +async def root(): + """Simple HTML client for testing""" + return """ + + + + VCK Online - Dev Client + + + +

VCK Online - Development Client

+ +
+

Lobby

+
+ + + +
+
+
+
+ +
+

Game

+
+
+
+
+
+
+ +
+ + +
+
+
+
+
+
+
+
+

Game log

+
+
+ +
+
Tableau seats: buttons are arranged in turn order around the Board.
+
+
+
+ Game state JSON +

+            
+
+ + + + + + + """ + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) + diff --git a/setup_venv.sh b/setup_venv.sh new file mode 100755 index 0000000..03e7b52 --- /dev/null +++ b/setup_venv.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Setup script for VCK Online virtual environment + +# Find mariadb_config +MARIADB_CONFIG_PATH=$(find /opt/homebrew -name mariadb_config 2>/dev/null | head -1) + +if [ -z "$MARIADB_CONFIG_PATH" ]; then + echo "Error: mariadb_config not found. Installing mariadb-connector-c..." + brew install mariadb-connector-c + MARIADB_CONFIG_PATH=$(find /opt/homebrew -name mariadb_config 2>/dev/null | head -1) +fi + +if [ -z "$MARIADB_CONFIG_PATH" ]; then + echo "Error: Could not find mariadb_config after installation." + echo "Please install manually: brew install mariadb-connector-c" + exit 1 +fi + +echo "Found mariadb_config at: $MARIADB_CONFIG_PATH" +echo "Setting MARIADB_CONFIG environment variable..." + +# Activate virtual environment if it exists +if [ -d ".venv" ]; then + source .venv/bin/activate + echo "Virtual environment activated" +else + echo "Creating virtual environment..." + python3 -m venv .venv + source .venv/bin/activate +fi + +# Set environment variable and install packages +export MARIADB_CONFIG="$MARIADB_CONFIG_PATH" +pip install -r requirements.txt + +echo "" +echo "Setup complete! To activate the environment in the future:" +echo " source .venv/bin/activate" +echo " export MARIADB_CONFIG=\"$MARIADB_CONFIG_PATH\"" + diff --git a/sql/INSTALL_PROCEDURES.md b/sql/INSTALL_PROCEDURES.md new file mode 100644 index 0000000..5f43c7b --- /dev/null +++ b/sql/INSTALL_PROCEDURES.md @@ -0,0 +1,107 @@ +# Installing Stored Procedures + +All stored procedure SQL files are ready to use. You have several options: + +## Prerequisites + +1. **SSH Port Forwarding** - Make sure you have an active SSH tunnel: + ```bash + ssh -L 3306:localhost:3306 lukesau.com + ``` + Keep this terminal open while running SQL commands. + +2. **MySQL Client** - Install if needed: + ```bash + brew install mysql-client + ``` + + To add mysql-client to your PATH permanently, add to `~/.zshrc`: + ```bash + echo 'export PATH="/opt/homebrew/opt/mysql-client/bin:$PATH"' >> ~/.zshrc + source ~/.zshrc + ``` + +## Option 1: Use the Helper Script (Easiest) + +```bash +./sql/run_sql.sh sql/create_all_stored_procedures.sql +``` + +## Option 2: Use MySQL Client Directly + +If mysql is in your PATH (added to ~/.zshrc), you can use: + +```bash +mysql -h 127.0.0.1 -P 3306 -u vckonline -p vckonline < sql/create_all_stored_procedures.sql +``` + +Or use the full path: + +```bash +/opt/homebrew/opt/mysql-client/bin/mysql -h 127.0.0.1 -P 3306 -u vckonline -p vckonline < sql/create_all_stored_procedures.sql +``` + +**Note:** The `-h 127.0.0.1 -P 3306` flags connect through your SSH tunnel to the remote database. + +## Option 3: Interactive MariaDB Session + +If you're already logged into MariaDB on the server: + +```sql +source sql/create_all_stored_procedures.sql; +``` + +## Option 4: Install Individually + +Run each procedure file separately using the helper script: + +```bash +./sql/run_sql.sh sql/select_base1_citizens_sp.sql +./sql/run_sql.sh sql/select_base1_monsters_sp.sql +./sql/run_sql.sh sql/select_base2_citizens_sp.sql +./sql/run_sql.sh sql/select_base2_monsters_sp.sql +./sql/run_sql.sh sql/select_random_domains_sp.sql +./sql/run_sql.sh sql/select_random_dukes_sp.sql +``` + +Or using mysql client directly: + +```bash +/opt/homebrew/Cellar/mysql-client/9.5.0/bin/mysql -h 127.0.0.1 -P 3306 -u vckonline -p vckonline < sql/select_base1_citizens_sp.sql +# ... repeat for each file +``` + +Or interactively in MariaDB on the server: + +```sql +source sql/select_base1_citizens_sp.sql; +source sql/select_base1_monsters_sp.sql; +source sql/select_base2_citizens_sp.sql; +source sql/select_base2_monsters_sp.sql; +source sql/select_random_domains_sp.sql; +source sql/select_random_dukes_sp.sql; +``` + +## Verify Installation + +After installing, verify with: + +```sql +SHOW PROCEDURE STATUS WHERE Db = 'vckonline'; +``` + +Or run the test script: + +```bash +python3 test_database.py +``` + +## What Each Procedure Does + +- **select_base1_citizens()** - Returns all citizens from base game 1 +- **select_base1_monsters()** - Returns all monsters from base game 1 +- **select_base2_citizens()** - Returns base game 2 citizens + Peasant and Knight from base1 +- **select_base2_monsters()** - Returns base game 2 monsters + 2 random areas from base1 +- **select_random_domains()** - Returns 15 random domains +- **select_random_dukes()** - Returns all dukes in random order + diff --git a/sql/USER_SETUP_GUIDE.md b/sql/USER_SETUP_GUIDE.md new file mode 100644 index 0000000..3b28fe7 --- /dev/null +++ b/sql/USER_SETUP_GUIDE.md @@ -0,0 +1,129 @@ +# MariaDB User Setup Guide for VCK Online + +Run these commands interactively in MariaDB as root to investigate and fix the user setup. + +## Investigation Commands + +### 1. Check if the database exists +```sql +SHOW DATABASES LIKE 'vckonline'; +``` + +### 2. Check if the user exists and from which hosts +```sql +SELECT User, Host FROM mysql.user WHERE User = 'vckonline'; +``` + +### 3. Check current privileges (if user exists) +```sql +SHOW GRANTS FOR 'vckonline'@'localhost'; +SHOW GRANTS FOR 'vckonline'@'127.0.0.1'; +SHOW GRANTS FOR 'vckonline'@'%'; +``` + +### 4. Check if database has data (if database exists) +```sql +USE vckonline; +SHOW TABLES; +``` + +### 5. Verify data exists in key tables +```sql +SELECT COUNT(*) AS citizens_count FROM citizens; +SELECT COUNT(*) AS monsters_count FROM monsters; +SELECT COUNT(*) AS domains_count FROM domains; +SELECT COUNT(*) AS dukes_count FROM dukes; +SELECT COUNT(*) AS starters_count FROM starters; +``` + +## Fix Commands + +### Scenario A: User doesn't exist - Create new user + +```sql +-- Create user for localhost connections +CREATE USER 'vckonline'@'localhost' IDENTIFIED BY 'vckonline'; + +-- Create user for 127.0.0.1 connections (SSH tunnel) +CREATE USER 'vckonline'@'127.0.0.1' IDENTIFIED BY 'vckonline'; + +-- Create user for remote connections (optional, for direct connections) +CREATE USER 'vckonline'@'%' IDENTIFIED BY 'vckonline'; + +-- Grant privileges +GRANT ALL PRIVILEGES ON vckonline.* TO 'vckonline'@'localhost'; +GRANT ALL PRIVILEGES ON vckonline.* TO 'vckonline'@'127.0.0.1'; +GRANT ALL PRIVILEGES ON vckonline.* TO 'vckonline'@'%'; + +-- Apply changes +FLUSH PRIVILEGES; +``` + +### Scenario B: User exists but password is wrong - Reset password + +```sql +-- Reset password for existing user +ALTER USER 'vckonline'@'localhost' IDENTIFIED BY 'vckonline'; +ALTER USER 'vckonline'@'127.0.0.1' IDENTIFIED BY 'vckonline'; +ALTER USER 'vckonline'@'%' IDENTIFIED BY 'vckonline'; + +-- Ensure privileges are granted +GRANT ALL PRIVILEGES ON vckonline.* TO 'vckonline'@'localhost'; +GRANT ALL PRIVILEGES ON vckonline.* TO 'vckonline'@'127.0.0.1'; +GRANT ALL PRIVILEGES ON vckonline.* TO 'vckonline'@'%'; + +-- Apply changes +FLUSH PRIVILEGES; +``` + +### Scenario C: User exists but lacks privileges - Grant privileges + +```sql +-- Grant privileges +GRANT ALL PRIVILEGES ON vckonline.* TO 'vckonline'@'localhost'; +GRANT ALL PRIVILEGES ON vckonline.* TO 'vckonline'@'127.0.0.1'; +GRANT ALL PRIVILEGES ON vckonline.* TO 'vckonline'@'%'; + +-- Apply changes +FLUSH PRIVILEGES; +``` + +## Verification + +After running the fix commands, verify the setup: + +```sql +-- Check user exists +SELECT User, Host FROM mysql.user WHERE User = 'vckonline'; + +-- Check privileges +SHOW GRANTS FOR 'vckonline'@'localhost'; +SHOW GRANTS FOR 'vckonline'@'127.0.0.1'; + +-- Test connection (from another terminal, not in MariaDB) +-- mysql -u vckonline -p vckonline +-- Password: vckonline +``` + +## Quick Fix (All-in-One) + +If you're confident the database exists with data, run this complete setup: + +```sql +-- Create users if they don't exist (will error if they do, that's OK) +CREATE USER IF NOT EXISTS 'vckonline'@'localhost' IDENTIFIED BY 'vckonline'; +CREATE USER IF NOT EXISTS 'vckonline'@'127.0.0.1' IDENTIFIED BY 'vckonline'; +CREATE USER IF NOT EXISTS 'vckonline'@'%' IDENTIFIED BY 'vckonline'; + +-- Grant privileges (safe to run even if already granted) +GRANT ALL PRIVILEGES ON vckonline.* TO 'vckonline'@'localhost'; +GRANT ALL PRIVILEGES ON vckonline.* TO 'vckonline'@'127.0.0.1'; +GRANT ALL PRIVILEGES ON vckonline.* TO 'vckonline'@'%'; + +-- Apply changes +FLUSH PRIVILEGES; + +-- Verify +SHOW GRANTS FOR 'vckonline'@'localhost'; +``` + diff --git a/sql/citizens_202304121016.sql b/sql/citizens_202304121016.sql index 446c7e4..2c983e7 100644 --- a/sql/citizens_202304121016.sql +++ b/sql/citizens_202304121016.sql @@ -1,6 +1,6 @@ INSERT INTO vckonline.citizens (name,gold_cost,roll_match1,roll_match2,shadow_count,holy_count,soldier_count,worker_count,gold_payout_on_turn,gold_payout_off_turn,strength_payout_on_turn,strength_payout_off_turn,magic_payout_on_turn,magic_payout_off_turn,has_special_payout_on_turn,has_special_payout_off_turn,special_payout_on_turn,special_payout_off_turn,special_citizen,image) VALUES ('Cleric',3,1,0,0,1,0,0,0,0,0,0,3,1,0,0,NULL,NULL,0,NULL), - ('Merchant',2,2,0,0,0,0,1,0,1,0,0,0,0,1,0,NULL,NULL,0,NULL), + ('Merchant',2,2,0,0,0,0,1,1,0,0,0,0,0,1,0,NULL,NULL,0,NULL), ('Mercenary',3,3,0,1,0,0,0,1,0,1,0,0,0,0,1,NULL,NULL,0,NULL), ('Archer',4,4,0,0,0,1,0,0,0,2,1,0,0,0,0,NULL,NULL,0,NULL), ('Peasant',2,5,0,0,0,0,1,1,1,0,0,0,0,0,0,NULL,NULL,0,NULL), diff --git a/sql/create_all_stored_procedures.sql b/sql/create_all_stored_procedures.sql new file mode 100644 index 0000000..ebcdb0a --- /dev/null +++ b/sql/create_all_stored_procedures.sql @@ -0,0 +1,76 @@ +-- Create all stored procedures for VCK Online +-- Run this file as the vckonline user (or any user with CREATE ROUTINE privilege on vckonline database) +-- Usage: mysql -u vckonline -p vckonline < create_all_stored_procedures.sql +-- Or interactively: source create_all_stored_procedures.sql; + +DELIMITER // + +-- Drop existing procedures if they exist (to allow re-running this script) +DROP PROCEDURE IF EXISTS select_base1_citizens // +DROP PROCEDURE IF EXISTS select_base1_monsters // +DROP PROCEDURE IF EXISTS select_base2_citizens // +DROP PROCEDURE IF EXISTS select_base2_monsters // +DROP PROCEDURE IF EXISTS select_random_domains // +DROP PROCEDURE IF EXISTS select_random_dukes // + +-- Base 1 Citizens +CREATE PROCEDURE select_base1_citizens() +BEGIN + SELECT * FROM citizens WHERE expansion = "base1"; +END // + +-- Base 1 Monsters +CREATE PROCEDURE select_base1_monsters() +BEGIN + SELECT * FROM monsters WHERE expansion = "base1"; +END // + +-- Base 2 Citizens +CREATE PROCEDURE select_base2_citizens() +BEGIN + SELECT * FROM citizens WHERE expansion = "base2" + UNION + SELECT * FROM citizens WHERE expansion = "base1" AND name IN ('Peasant', 'Knight'); +END // + +-- Base 2 Monsters +CREATE PROCEDURE select_base2_monsters() +BEGIN + DECLARE chosen_area1 VARCHAR(255); + DECLARE chosen_area2 VARCHAR(255); + SET chosen_area1 = ( + SELECT area FROM monsters WHERE expansion = 'base1' GROUP BY area ORDER BY RAND() LIMIT 1 + ); + SET chosen_area2 = ( + SELECT area FROM monsters WHERE expansion = 'base1' AND area <> chosen_area1 ORDER BY RAND() LIMIT 1 + ); + SELECT id_monsters, name, area, monster_type, monster_order, + strength_cost, magic_cost, vp_reward, gold_reward, strength_reward, magic_reward, + has_special_reward, special_reward, has_special_cost, special_cost, is_extra, expansion + FROM monsters + WHERE expansion = 'base2' + UNION + SELECT id_monsters, name, area, monster_type, monster_order, + strength_cost, magic_cost, vp_reward, gold_reward, strength_reward, magic_reward, + has_special_reward, special_reward, has_special_cost, special_cost, is_extra, expansion + FROM monsters + WHERE expansion = 'base1' AND area IN (chosen_area1, chosen_area2); +END // + +-- Random Domains +CREATE PROCEDURE select_random_domains() +BEGIN + SELECT * FROM domains ORDER BY RAND() LIMIT 15; +END // + +-- Random Dukes +CREATE PROCEDURE select_random_dukes() +BEGIN + SELECT * FROM dukes ORDER BY RAND(); +END // + +DELIMITER ; + +-- Verify procedures were created +SHOW PROCEDURE STATUS WHERE Db = 'vckonline'; + diff --git a/sql/fix_user_setup.sql b/sql/fix_user_setup.sql new file mode 100644 index 0000000..750a2fd --- /dev/null +++ b/sql/fix_user_setup.sql @@ -0,0 +1,56 @@ +-- ============================================ +-- VCK Online Database User Setup Commands +-- Run these interactively in MariaDB as root +-- ============================================ + +-- Step 1: Check if the database exists +SHOW DATABASES LIKE 'vckonline'; + +-- Step 2: Check if the user exists +SELECT User, Host FROM mysql.user WHERE User = 'vckonline'; + +-- Step 3: Check current user privileges (if user exists) +SHOW GRANTS FOR 'vckonline'@'localhost'; +SHOW GRANTS FOR 'vckonline'@'%'; + +-- Step 4: Check what tables exist in the database (if it exists) +USE vckonline; +SHOW TABLES; + +-- Step 5: Check table row counts to verify data exists +SELECT + 'citizens' AS table_name, COUNT(*) AS row_count FROM citizens +UNION ALL +SELECT 'monsters', COUNT(*) FROM monsters +UNION ALL +SELECT 'domains', COUNT(*) FROM domains +UNION ALL +SELECT 'dukes', COUNT(*) FROM dukes +UNION ALL +SELECT 'starters', COUNT(*) FROM starters; + +-- ============================================ +-- FIX COMMANDS (run only if needed) +-- ============================================ + +-- Option A: If user doesn't exist, create it +-- CREATE USER 'vckonline'@'localhost' IDENTIFIED BY 'vckonline'; +-- CREATE USER 'vckonline'@'127.0.0.1' IDENTIFIED BY 'vckonline'; +-- CREATE USER 'vckonline'@'%' IDENTIFIED BY 'vckonline'; + +-- Option B: If user exists but password is wrong, reset it +-- ALTER USER 'vckonline'@'localhost' IDENTIFIED BY 'vckonline'; +-- ALTER USER 'vckonline'@'127.0.0.1' IDENTIFIED BY 'vckonline'; +-- ALTER USER 'vckonline'@'%' IDENTIFIED BY 'vckonline'; + +-- Grant all privileges on vckonline database +-- GRANT ALL PRIVILEGES ON vckonline.* TO 'vckonline'@'localhost'; +-- GRANT ALL PRIVILEGES ON vckonline.* TO 'vckonline'@'127.0.0.1'; +-- GRANT ALL PRIVILEGES ON vckonline.* TO 'vckonline'@'%'; + +-- Flush privileges to apply changes +-- FLUSH PRIVILEGES; + +-- Verify the grants after creating/fixing +-- SHOW GRANTS FOR 'vckonline'@'localhost'; + diff --git a/sql/run_sql.sh b/sql/run_sql.sh new file mode 100755 index 0000000..2c4bea9 --- /dev/null +++ b/sql/run_sql.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Helper script to run SQL files on remote database through SSH port forwarding +# Usage: ./sql/run_sql.sh sql/your_file.sql +# Or: bash sql/run_sql.sh sql/your_file.sql + +# Find mysql binary (try PATH first, then specific location) +if command -v mysql >/dev/null 2>&1; then + MYSQL_BIN="mysql" +elif [ -f "/opt/homebrew/opt/mysql-client/bin/mysql" ]; then + MYSQL_BIN="/opt/homebrew/opt/mysql-client/bin/mysql" +elif [ -f "/opt/homebrew/Cellar/mysql-client/9.5.0/bin/mysql" ]; then + MYSQL_BIN="/opt/homebrew/Cellar/mysql-client/9.5.0/bin/mysql" +else + echo "Error: mysql binary not found" + echo "Please install mysql-client: brew install mysql-client" + echo "Or add to PATH: export PATH=\"/opt/homebrew/opt/mysql-client/bin:\$PATH\"" + exit 1 +fi + +# Check if SQL file is provided +if [ -z "$1" ]; then + echo "Usage: $0 " + echo "Example: $0 sql/create_all_stored_procedures.sql" + exit 1 +fi + +SQL_FILE="$1" + +# Check if SQL file exists +if [ ! -f "$SQL_FILE" ]; then + echo "Error: SQL file not found: $SQL_FILE" + exit 1 +fi + +# Check if port 3306 is accessible (SSH tunnel check) +if ! nc -z 127.0.0.1 3306 2>/dev/null; then + echo "Warning: Cannot connect to localhost:3306" + echo "Make sure SSH port forwarding is active:" + echo " ssh -L 3306:localhost:3306 lukesau.com" + echo "" + read -p "Continue anyway? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi +fi + +# Run the SQL file +echo "Running SQL file: $SQL_FILE" +echo "Connecting to database through SSH tunnel (localhost:3306)..." +echo "" + +"$MYSQL_BIN" -h 127.0.0.1 -P 3306 -u vckonline -p vckonline < "$SQL_FILE" + +if [ $? -eq 0 ]; then + echo "" + echo "✓ SQL file executed successfully" +else + echo "" + echo "✗ Error executing SQL file" + exit 1 +fi + diff --git a/test_database.py b/test_database.py new file mode 100644 index 0000000..25591d7 --- /dev/null +++ b/test_database.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +""" +Test script to check MariaDB database connection and status for VCK Online +""" + +import sys + +def test_database_connection(): + """Test the database connection and basic functionality""" + + # Database connection parameters from game.py + # Using localhost for SSH port forwarding (ssh -L 3306:localhost:3306 lukesau.com) + db_config = { + 'user': 'vckonline', + 'password': 'vckonline', + 'host': '127.0.0.1', + 'database': 'vckonline' + } + + print("Testing VCK Online Database Connection") + print("=" * 50) + + # Test 1: Check if mariadb module is available + print("\n1. Checking mariadb module...") + try: + import mariadb + print(" ✓ mariadb module found") + except ImportError: + print(" ✗ mariadb module not found") + print(" Install with: pip install mariadb") + print(" Or with user flag: pip install --user mariadb") + print(" Or use virtualenv: python3 -m venv .env && source .env/bin/activate && pip install mariadb") + return False + + # Test 1.5: Check if database server is accessible + print("\n1.5. Checking if database server is accessible on localhost:3306...") + print(" (Make sure SSH port forwarding is active: ssh -L 3306:localhost:3306 lukesau.com)") + import socket + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + result = sock.connect_ex(('127.0.0.1', 3306)) + sock.close() + if result == 0: + print(" ✓ Database server is accessible on localhost:3306") + else: + print(" ✗ Cannot reach localhost:3306") + print("\n Make sure SSH port forwarding is active:") + print(" ssh -L 3306:localhost:3306 lukesau.com") + return False + + # Test 2: Test connection + print("\n2. Testing database connection...") + try: + connection = mariadb.connect(**db_config) + print(f" ✓ Successfully connected to database '{db_config['database']}'") + cursor = connection.cursor(dictionary=True) + except mariadb.Error as e: + print(f" ✗ Connection failed: {e}") + print("\n Possible issues:") + print(" - Database 'vckonline' does not exist") + print(" - User 'vckonline' does not exist or password is incorrect") + print(" - User does not have permission to access from this IP") + return False + + # Test 3: Check if required tables exist + print("\n3. Checking required tables...") + required_tables = ['citizens', 'monsters', 'domains', 'dukes', 'starters'] + existing_tables = [] + + try: + cursor.execute("SHOW TABLES") + # SHOW TABLES returns a dict with key like 'Tables_in_vckonline' + tables = [list(row.values())[0] for row in cursor.fetchall()] + + for table in required_tables: + if table in tables: + print(f" ✓ Table '{table}' exists") + existing_tables.append(table) + else: + print(f" ✗ Table '{table}' is missing") + except mariadb.Error as e: + print(f" ✗ Error checking tables: {e}") + connection.close() + return False + + # Test 4: Check table row counts + print("\n4. Checking table data...") + for table in existing_tables: + try: + cursor.execute(f"SELECT COUNT(*) as count FROM {table}") + count = cursor.fetchone()['count'] + print(f" {table}: {count} rows") + except mariadb.Error as e: + print(f" ✗ Error counting rows in '{table}': {e}") + + # Test 4.5: Display all card data + print("\n4.5. Displaying all card data...") + + # Citizens + try: + cursor.execute("SELECT name, gold_cost, roll_match1, roll_match2, shadow_count, holy_count, soldier_count, worker_count, expansion FROM citizens ORDER BY expansion, roll_match1") + citizens = cursor.fetchall() + print(f"\n CITIZENS ({len(citizens)} total):") + for c in citizens: + name, gc, r1, r2, sh, ho, so, wo, exp = c + roles = [] + if sh: roles.append(f"{sh} Shadow") + if ho: roles.append(f"{ho} Holy") + if so: roles.append(f"{so} Soldier") + if wo: roles.append(f"{wo} Worker") + role_str = ", ".join(roles) if roles else "No roles" + roll_str = f"{r1}" + (f"/{r2}" if r2 else "") + print(f" {name:20} | Cost: {gc:2}gp | Roll: {roll_str:5} | {role_str:20} | {exp}") + except mariadb.Error as e: + print(f" ✗ Error fetching citizens: {e}") + + # Monsters + try: + cursor.execute("SELECT name, area, monster_type, monster_order, strength_cost, magic_cost, vp_reward, gold_reward, strength_reward, magic_reward, expansion FROM monsters ORDER BY area, monster_order") + monsters = cursor.fetchall() + print(f"\n MONSTERS ({len(monsters)} total):") + for m in monsters: + name, area, mtype, order, sc, mc, vp, gr, sr, mr, exp = m + cost_str = f"{sc}sp" + (f" + {mc}mp" if mc else "") + reward_str = f"{vp}vp" + (f" + {gr}gp" if gr else "") + (f" + {sr}sp" if sr else "") + (f" + {mr}mp" if mr else "") + print(f" {name:25} | {area:10} | {mtype:8} | Cost: {cost_str:10} | Reward: {reward_str:15} | {exp}") + except mariadb.Error as e: + print(f" ✗ Error fetching monsters: {e}") + + # Domains + try: + cursor.execute("SELECT name, gold_cost, shadow_count, holy_count, soldier_count, worker_count, vp_reward, text, expansion FROM domains ORDER BY expansion, gold_cost") + domains = cursor.fetchall() + print(f"\n DOMAINS ({len(domains)} total):") + for d in domains: + name, gc, sh, ho, so, wo, vp, text, exp = d + roles = [] + if sh: roles.append(f"{sh} Shadow") + if ho: roles.append(f"{ho} Holy") + if so: roles.append(f"{so} Soldier") + if wo: roles.append(f"{wo} Worker") + role_str = ", ".join(roles) if roles else "No roles" + text_preview = (text[:40] + "...") if text and len(text) > 40 else (text or "No effect") + print(f" {name:25} | Cost: {gc:2}gp | {role_str:20} | {vp}vp | {text_preview} | {exp}") + except mariadb.Error as e: + print(f" ✗ Error fetching domains: {e}") + + # Dukes + try: + cursor.execute("SELECT name, gold_mult, strength_mult, magic_mult, shadow_mult, holy_mult, soldier_mult, worker_mult, monster_mult, citizen_mult, domain_mult, expansion FROM dukes ORDER BY expansion, name") + dukes = cursor.fetchall() + print(f"\n DUKES ({len(dukes)} total):") + for d in dukes: + name, gm, sm, mm, shm, hom, som, wom, mom, cm, dom, exp = d + mults = [] + if gm: mults.append(f"Gold×{gm}") + if sm: mults.append(f"Str×{sm}") + if mm: mults.append(f"Mag×{mm}") + if shm: mults.append(f"Shadow×{shm}") + if hom: mults.append(f"Holy×{hom}") + if som: mults.append(f"Soldier×{som}") + if wom: mults.append(f"Worker×{wom}") + if mom: mults.append(f"Monster×{mom}") + if cm: mults.append(f"Citizen×{cm}") + if dom: mults.append(f"Domain×{dom}") + mult_str = ", ".join(mults) if mults else "No multipliers" + print(f" {name:30} | {mult_str} | {exp}") + except mariadb.Error as e: + print(f" ✗ Error fetching dukes: {e}") + + # Starters + try: + cursor.execute("SELECT name, roll_match1, roll_match2, gold_payout_on_turn, gold_payout_off_turn, strength_payout_on_turn, strength_payout_off_turn, magic_payout_on_turn, magic_payout_off_turn, expansion FROM starters ORDER BY roll_match1") + starters = cursor.fetchall() + print(f"\n STARTERS ({len(starters)} total):") + for s in starters: + name, r1, r2, gpot, gpoff, spot, spoff, mpot, mpoff, exp = s + roll_str = f"{r1}" + (f"/{r2}" if r2 else "") + payouts = [] + if gpot: payouts.append(f"{gpot}gp (on)") + if gpoff: payouts.append(f"{gpoff}gp (off)") + if spot: payouts.append(f"{spot}sp (on)") + if spoff: payouts.append(f"{spoff}sp (off)") + if mpot: payouts.append(f"{mpot}mp (on)") + if mpoff: payouts.append(f"{mpoff}mp (off)") + payout_str = ", ".join(payouts) if payouts else "No payouts" + print(f" {name:20} | Roll: {roll_str:5} | {payout_str} | {exp}") + except mariadb.Error as e: + print(f" ✗ Error fetching starters: {e}") + + # Test 5: Test stored procedures + print("\n5. Checking stored procedures...") + print(" (These are helper functions used by the game code to select cards)") + required_procedures = [ + 'select_base1_citizens', + 'select_base1_monsters', + 'select_base2_citizens', + 'select_base2_monsters', + 'select_random_domains', + 'select_random_dukes' + ] + + try: + cursor.execute("SHOW PROCEDURE STATUS WHERE Db = 'vckonline'") + procedures = [row['Name'] for row in cursor.fetchall()] + + missing_procedures = [] + for proc in required_procedures: + if proc in procedures: + print(f" ✓ Procedure '{proc}' exists") + else: + print(f" ✗ Procedure '{proc}' is missing") + missing_procedures.append(proc) + + if missing_procedures: + print(f"\n Note: {len(missing_procedures)} stored procedures are missing.") + print(" These can be created from the SQL files in the sql/ directory:") + print(" - select_base1_citizens_sp.sql") + print(" - select_base1_monsters_sp.sql") + print(" - select_base2_citizens_sp.sql") + print(" - select_base2_monsters_sp.sql") + print(" - select_random_domains_sp.sql") + print(" - select_random_dukes_sp.sql") + except mariadb.Error as e: + print(f" ✗ Error checking procedures: {e}") + + # Test 6: Test a sample query + print("\n6. Testing sample query...") + try: + cursor.execute("SELECT * FROM starters LIMIT 1") + result = cursor.fetchone() + if result: + name = result.get('name', 'N/A') + print(f" ✓ Sample query successful (found starter: {name})") + else: + print(" ⚠ Sample query returned no results (table may be empty)") + except mariadb.Error as e: + print(f" ✗ Sample query failed: {e}") + + # Test 7: Test all stored procedures and display results + print("\n7. Testing stored procedures and displaying results...") + + # Get list of available procedures + try: + cursor.execute("SHOW PROCEDURE STATUS WHERE Db = 'vckonline'") + available_procedures = {row['Name']: True for row in cursor.fetchall()} + except mariadb.Error as e: + print(f" ✗ Error checking procedures: {e}") + available_procedures = {} + + # Test each procedure + procedure_tests = [ + ('select_base1_citizens', 'Citizens'), + ('select_base1_monsters', 'Monsters'), + ('select_base2_citizens', 'Citizens'), + ('select_base2_monsters', 'Monsters'), + ('select_random_domains', 'Domains'), + ('select_random_dukes', 'Dukes') + ] + + for proc_name, card_type in procedure_tests: + if proc_name not in available_procedures: + print(f"\n ✗ {proc_name} - Procedure not found (skipping)") + continue + + try: + print(f"\n Testing {proc_name}():") + cursor.callproc(proc_name) + results = cursor.fetchall() + + if not results: + print(f" ⚠ No results returned") + continue + + print(f" ✓ Returned {len(results)} {card_type.lower()}") + + # Display results based on card type (using dictionary access) + if card_type == 'Citizens': + print(f" {card_type} returned:") + for row in results: + name = row.get('name', 'N/A') + gc = row.get('gold_cost') + r1 = row.get('roll_match1') + r2 = row.get('roll_match2') + sh = row.get('shadow_count', 0) or 0 + ho = row.get('holy_count', 0) or 0 + so = row.get('soldier_count', 0) or 0 + wo = row.get('worker_count', 0) or 0 + exp = row.get('expansion') + + roles = [] + if sh: roles.append(f"{sh} Shadow") + if ho: roles.append(f"{ho} Holy") + if so: roles.append(f"{so} Soldier") + if wo: roles.append(f"{wo} Worker") + role_str = ", ".join(roles) if roles else "No roles" + roll_str = f"{r1}" + (f"/{r2}" if r2 and r2 > 0 else "") + gc_str = f"{gc}gp" if gc is not None else "N/A" + exp_str = f" | {exp}" if exp else "" + print(f" {name:20} | Cost: {gc_str:5} | Roll: {roll_str:5} | {role_str:20}{exp_str}") + + elif card_type == 'Monsters': + print(f" {card_type} returned:") + for row in results: + name = row.get('name', 'N/A') + area = row.get('area') + mtype = row.get('monster_type') + sc = row.get('strength_cost', 0) or 0 + mc = row.get('magic_cost', 0) or 0 + vp = row.get('vp_reward', 0) or 0 + gr = row.get('gold_reward', 0) or 0 + exp = row.get('expansion') + + cost_str = f"{sc}sp" + (f" + {mc}mp" if mc else "") + reward_str = f"{vp}vp" + (f" + {gr}gp" if gr else "") + exp_str = f" | {exp}" if exp else "" + print(f" {name:25} | {area:10} | {mtype:8} | Cost: {cost_str:10} | Reward: {reward_str:15}{exp_str}") + + elif card_type == 'Domains': + print(f" {card_type} returned:") + for row in results: + name = row.get('name', 'N/A') + gc = row.get('gold_cost') + vp = row.get('vp_reward') + text = row.get('text') + + text_preview = (text[:50] + "...") if text and len(text) > 50 else (text or "No effect") + print(f" {name:25} | Cost: {gc:2}gp | {vp}vp | {text_preview}") + + elif card_type == 'Dukes': + print(f" {card_type} returned:") + for row in results: + name = row.get('name', 'N/A') + gm = row.get('gold_mult', 0) or 0 + sm = row.get('strength_mult', 0) or 0 + mm = row.get('magic_mult', 0) or 0 + + mults = [] + if gm: mults.append(f"Gold×{gm}") + if sm: mults.append(f"Str×{sm}") + if mm: mults.append(f"Mag×{mm}") + mult_str = ", ".join(mults) if mults else "No multipliers" + print(f" {name:30} | {mult_str}") + + except mariadb.Error as e: + print(f" ✗ Error calling {proc_name}: {e}") + + # Cleanup + cursor.close() + connection.close() + print("\n" + "=" * 50) + print("Database test completed") + return True + + +if __name__ == "__main__": + success = test_database_connection() + sys.exit(0 if success else 1) +