Spaces:
Sleeping
Sleeping
feat(block17): $CLAY simulated economy + single-match wagering
Browse files- ClayBalance + ClayTransaction models (integer cents) + Alembic migration 0019
- ClayLedger abstract interface + SqliteClayLedger concrete impl (get_ledger singleton)
- Signup hook grants 100 $CLAY starting grant; idempotent backfill script
- POST /agents/{id}/play-kenji: stake input, debit on match create
- Post-match processor _settle_wager: win=2x, draw/abandoned=refund, loss=audit marker
- Nav balance display (Jinja2 global) + /me/clay-history paginated history
- Wager banner in play.html (live outcome update) + wager section in _summary_body.html
- 21 passing tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- alembic/versions/0019_clay_economy.py +131 -0
- app/config.py +6 -0
- app/db.py +1 -1
- app/economy/__init__.py +3 -0
- app/economy/clay_ledger.py +228 -0
- app/main.py +5 -0
- app/models/__init__.py +4 -0
- app/models/clay_balance.py +25 -0
- app/models/clay_transaction.py +41 -0
- app/models/match.py +7 -0
- app/post_match/processor.py +74 -0
- app/web/routes.py +189 -0
- app/web/templates/_summary_body.html +17 -0
- app/web/templates/agent_detail.html +52 -13
- app/web/templates/base.html +11 -0
- app/web/templates/clay_history.html +100 -0
- app/web/templates/play.html +26 -0
- scripts/grant_starting_clay.py +56 -0
- tests/conftest.py +4 -0
- tests/test_block17_clay_economy.py +612 -0
alembic/versions/0019_clay_economy.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Block 17 — $CLAY economy: clay_balances, clay_transactions, match stake columns.
|
| 2 |
+
|
| 3 |
+
Idempotent — running twice is safe (all DDL is guarded by existence checks).
|
| 4 |
+
|
| 5 |
+
Revision ID: 0019_clay_economy
|
| 6 |
+
Revises: 0018_memory_agent_id
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import Sequence, Union
|
| 12 |
+
|
| 13 |
+
import sqlalchemy as sa
|
| 14 |
+
from alembic import op
|
| 15 |
+
from sqlalchemy import inspect
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _has_table(name: str) -> bool:
|
| 19 |
+
return name in inspect(op.get_bind()).get_table_names()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _has_column(table: str, column: str) -> bool:
|
| 23 |
+
if not _has_table(table):
|
| 24 |
+
return False
|
| 25 |
+
cols = {r["name"] for r in inspect(op.get_bind()).get_columns(table)}
|
| 26 |
+
return column in cols
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _has_index(table: str, index_name: str) -> bool:
|
| 30 |
+
if not _has_table(table):
|
| 31 |
+
return False
|
| 32 |
+
idxs = {i["name"] for i in inspect(op.get_bind()).get_indexes(table)}
|
| 33 |
+
return index_name in idxs
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
revision: str = "0019_clay_economy"
|
| 37 |
+
down_revision: Union[str, None] = "0018_memory_agent_id"
|
| 38 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 39 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def upgrade() -> None:
|
| 43 |
+
# --- clay_balances ---------------------------------------------------------
|
| 44 |
+
if not _has_table("clay_balances"):
|
| 45 |
+
op.create_table(
|
| 46 |
+
"clay_balances",
|
| 47 |
+
sa.Column(
|
| 48 |
+
"player_id",
|
| 49 |
+
sa.String(36),
|
| 50 |
+
sa.ForeignKey("players.id", ondelete="CASCADE"),
|
| 51 |
+
primary_key=True,
|
| 52 |
+
),
|
| 53 |
+
sa.Column("balance", sa.Integer, nullable=False, server_default="0"),
|
| 54 |
+
sa.Column("updated_at", sa.DateTime, nullable=False),
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# --- clay_transactions -----------------------------------------------------
|
| 58 |
+
if not _has_table("clay_transactions"):
|
| 59 |
+
op.create_table(
|
| 60 |
+
"clay_transactions",
|
| 61 |
+
sa.Column("id", sa.String(36), primary_key=True),
|
| 62 |
+
sa.Column(
|
| 63 |
+
"player_id",
|
| 64 |
+
sa.String(36),
|
| 65 |
+
sa.ForeignKey("players.id", ondelete="CASCADE"),
|
| 66 |
+
nullable=False,
|
| 67 |
+
),
|
| 68 |
+
sa.Column("amount", sa.Integer, nullable=False),
|
| 69 |
+
sa.Column("balance_after", sa.Integer, nullable=False),
|
| 70 |
+
sa.Column("reason", sa.String(64), nullable=False),
|
| 71 |
+
sa.Column(
|
| 72 |
+
"related_match_id",
|
| 73 |
+
sa.String(36),
|
| 74 |
+
sa.ForeignKey("matches.id", ondelete="SET NULL"),
|
| 75 |
+
nullable=True,
|
| 76 |
+
),
|
| 77 |
+
sa.Column("created_at", sa.DateTime, nullable=False),
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
if not _has_index("clay_transactions", "ix_clay_transactions_player_id"):
|
| 81 |
+
op.create_index(
|
| 82 |
+
"ix_clay_transactions_player_id", "clay_transactions", ["player_id"]
|
| 83 |
+
)
|
| 84 |
+
if not _has_index("clay_transactions", "ix_clay_transactions_match_id"):
|
| 85 |
+
op.create_index(
|
| 86 |
+
"ix_clay_transactions_match_id",
|
| 87 |
+
"clay_transactions",
|
| 88 |
+
["related_match_id"],
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# --- matches: stake columns ------------------------------------------------
|
| 92 |
+
if not _has_table("matches"):
|
| 93 |
+
return
|
| 94 |
+
|
| 95 |
+
needs_stake = not _has_column("matches", "stake_cents")
|
| 96 |
+
needs_settled = not _has_column("matches", "stake_settled_at")
|
| 97 |
+
|
| 98 |
+
if needs_stake or needs_settled:
|
| 99 |
+
with op.batch_alter_table("matches") as batch_op:
|
| 100 |
+
if needs_stake:
|
| 101 |
+
batch_op.add_column(
|
| 102 |
+
sa.Column(
|
| 103 |
+
"stake_cents",
|
| 104 |
+
sa.Integer,
|
| 105 |
+
nullable=False,
|
| 106 |
+
server_default="0",
|
| 107 |
+
)
|
| 108 |
+
)
|
| 109 |
+
if needs_settled:
|
| 110 |
+
batch_op.add_column(
|
| 111 |
+
sa.Column("stake_settled_at", sa.DateTime, nullable=True)
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def downgrade() -> None:
|
| 116 |
+
if _has_index("clay_transactions", "ix_clay_transactions_player_id"):
|
| 117 |
+
op.drop_index("ix_clay_transactions_player_id", "clay_transactions")
|
| 118 |
+
if _has_index("clay_transactions", "ix_clay_transactions_match_id"):
|
| 119 |
+
op.drop_index("ix_clay_transactions_match_id", "clay_transactions")
|
| 120 |
+
|
| 121 |
+
if _has_table("clay_transactions"):
|
| 122 |
+
op.drop_table("clay_transactions")
|
| 123 |
+
if _has_table("clay_balances"):
|
| 124 |
+
op.drop_table("clay_balances")
|
| 125 |
+
|
| 126 |
+
if _has_table("matches"):
|
| 127 |
+
with op.batch_alter_table("matches") as batch_op:
|
| 128 |
+
if _has_column("matches", "stake_settled_at"):
|
| 129 |
+
batch_op.drop_column("stake_settled_at")
|
| 130 |
+
if _has_column("matches", "stake_cents"):
|
| 131 |
+
batch_op.drop_column("stake_cents")
|
app/config.py
CHANGED
|
@@ -84,6 +84,12 @@ class Settings(BaseSettings):
|
|
| 84 |
# Defaults to the project owner; set to "" to disable the admin view.
|
| 85 |
admin_username: str = "forkei"
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
@property
|
| 88 |
def log_path(self) -> Path:
|
| 89 |
p = Path(self.log_dir)
|
|
|
|
| 84 |
# Defaults to the project owner; set to "" to disable the admin view.
|
| 85 |
admin_username: str = "forkei"
|
| 86 |
|
| 87 |
+
# Block 17: $CLAY simulated economy.
|
| 88 |
+
# Starting grant in cents (default 10000 = 100 $CLAY).
|
| 89 |
+
starting_clay_grant: int = 10000
|
| 90 |
+
# Max stake per single agent-vs-character match (cents). 10000 = 100 $CLAY.
|
| 91 |
+
max_stake_cents: int = 10000
|
| 92 |
+
|
| 93 |
@property
|
| 94 |
def log_path(self) -> Path:
|
| 95 |
p = Path(self.log_dir)
|
app/db.py
CHANGED
|
@@ -62,7 +62,7 @@ def get_session() -> Iterator[Session]:
|
|
| 62 |
|
| 63 |
def init_db() -> None:
|
| 64 |
from app.models.base import Base # noqa: F401 — import side effect
|
| 65 |
-
from app.models import auth, character, chat, evolution, feedback, lobby, match, memory, player_agent # noqa: F401
|
| 66 |
|
| 67 |
Base.metadata.create_all(bind=engine)
|
| 68 |
|
|
|
|
| 62 |
|
| 63 |
def init_db() -> None:
|
| 64 |
from app.models.base import Base # noqa: F401 — import side effect
|
| 65 |
+
from app.models import auth, character, chat, clay_balance, clay_transaction, evolution, feedback, lobby, match, memory, player_agent # noqa: F401
|
| 66 |
|
| 67 |
Base.metadata.create_all(bind=engine)
|
| 68 |
|
app/economy/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.economy.clay_ledger import ClayLedger, InsufficientFunds, SqliteClayLedger, get_ledger
|
| 2 |
+
|
| 3 |
+
__all__ = ["ClayLedger", "InsufficientFunds", "SqliteClayLedger", "get_ledger"]
|
app/economy/clay_ledger.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""$CLAY balance ledger.
|
| 2 |
+
|
| 3 |
+
V1: SQLite-backed internal ledger. All wagering happens within Chess Club's DB.
|
| 4 |
+
Future: when Bhaven's Metropolis backend exposes an internal balance system, OR
|
| 5 |
+
when on-chain wagering is built, the implementation behind this interface swaps
|
| 6 |
+
without changing call sites.
|
| 7 |
+
|
| 8 |
+
All call sites import via:
|
| 9 |
+
from app.economy.clay_ledger import get_ledger, InsufficientFunds
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
from typing import TYPE_CHECKING
|
| 17 |
+
|
| 18 |
+
from sqlalchemy import select
|
| 19 |
+
|
| 20 |
+
from app.models.clay_balance import ClayBalance
|
| 21 |
+
from app.models.clay_transaction import ClayTransaction
|
| 22 |
+
|
| 23 |
+
if TYPE_CHECKING:
|
| 24 |
+
pass
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class InsufficientFunds(Exception):
|
| 30 |
+
"""Raised by ClayLedger.debit / transfer when balance < amount."""
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class ClayLedger:
|
| 34 |
+
"""Abstract interface — concrete impl is SqliteClayLedger for now."""
|
| 35 |
+
|
| 36 |
+
def get_balance(self, player_id: str) -> int:
|
| 37 |
+
raise NotImplementedError
|
| 38 |
+
|
| 39 |
+
def debit(
|
| 40 |
+
self, player_id: str, amount: int, reason: str, match_id: str | None = None
|
| 41 |
+
) -> ClayTransaction:
|
| 42 |
+
raise NotImplementedError
|
| 43 |
+
|
| 44 |
+
def credit(
|
| 45 |
+
self, player_id: str, amount: int, reason: str, match_id: str | None = None
|
| 46 |
+
) -> ClayTransaction:
|
| 47 |
+
raise NotImplementedError
|
| 48 |
+
|
| 49 |
+
def transfer(
|
| 50 |
+
self,
|
| 51 |
+
from_id: str,
|
| 52 |
+
to_id: str,
|
| 53 |
+
amount: int,
|
| 54 |
+
reason: str,
|
| 55 |
+
match_id: str | None = None,
|
| 56 |
+
) -> tuple[ClayTransaction, ClayTransaction]:
|
| 57 |
+
raise NotImplementedError
|
| 58 |
+
|
| 59 |
+
def transactions_for_player(
|
| 60 |
+
self,
|
| 61 |
+
player_id: str,
|
| 62 |
+
limit: int = 50,
|
| 63 |
+
reason: str | None = None,
|
| 64 |
+
) -> list[ClayTransaction]:
|
| 65 |
+
raise NotImplementedError
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class SqliteClayLedger(ClayLedger):
|
| 69 |
+
"""Internal SQLite-backed implementation.
|
| 70 |
+
|
| 71 |
+
Each public method opens its own session and commits (or rolls back on
|
| 72 |
+
error). All balance + transaction inserts happen in one DB transaction
|
| 73 |
+
so the audit log is always consistent with the running total.
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
def __init__(self, session_factory) -> None:
|
| 77 |
+
self._factory = session_factory
|
| 78 |
+
|
| 79 |
+
# --- Internal helpers --------------------------------------------------
|
| 80 |
+
|
| 81 |
+
def _ensure_balance(self, session, player_id: str) -> ClayBalance:
|
| 82 |
+
"""Return the ClayBalance row, creating it (balance=0) if absent."""
|
| 83 |
+
row = session.get(ClayBalance, player_id)
|
| 84 |
+
if row is None:
|
| 85 |
+
row = ClayBalance(
|
| 86 |
+
player_id=player_id,
|
| 87 |
+
balance=0,
|
| 88 |
+
updated_at=datetime.utcnow(),
|
| 89 |
+
)
|
| 90 |
+
session.add(row)
|
| 91 |
+
session.flush()
|
| 92 |
+
return row
|
| 93 |
+
|
| 94 |
+
# --- Public interface --------------------------------------------------
|
| 95 |
+
|
| 96 |
+
def get_balance(self, player_id: str) -> int:
|
| 97 |
+
with self._factory() as session:
|
| 98 |
+
row = session.get(ClayBalance, player_id)
|
| 99 |
+
return row.balance if row else 0
|
| 100 |
+
|
| 101 |
+
def debit(
|
| 102 |
+
self, player_id: str, amount: int, reason: str, match_id: str | None = None
|
| 103 |
+
) -> ClayTransaction:
|
| 104 |
+
if amount <= 0:
|
| 105 |
+
raise ValueError(f"debit amount must be positive, got {amount}")
|
| 106 |
+
with self._factory() as session:
|
| 107 |
+
bal = self._ensure_balance(session, player_id)
|
| 108 |
+
if bal.balance < amount:
|
| 109 |
+
raise InsufficientFunds(
|
| 110 |
+
f"Balance {bal.balance} < required {amount} for player {player_id}"
|
| 111 |
+
)
|
| 112 |
+
bal.balance -= amount
|
| 113 |
+
bal.updated_at = datetime.utcnow()
|
| 114 |
+
txn = ClayTransaction(
|
| 115 |
+
player_id=player_id,
|
| 116 |
+
amount=-amount,
|
| 117 |
+
balance_after=bal.balance,
|
| 118 |
+
reason=reason,
|
| 119 |
+
related_match_id=match_id,
|
| 120 |
+
created_at=datetime.utcnow(),
|
| 121 |
+
)
|
| 122 |
+
session.add(txn)
|
| 123 |
+
session.commit()
|
| 124 |
+
session.refresh(txn)
|
| 125 |
+
return txn
|
| 126 |
+
|
| 127 |
+
def credit(
|
| 128 |
+
self, player_id: str, amount: int, reason: str, match_id: str | None = None
|
| 129 |
+
) -> ClayTransaction:
|
| 130 |
+
if amount < 0:
|
| 131 |
+
raise ValueError(f"credit amount must be non-negative, got {amount}")
|
| 132 |
+
with self._factory() as session:
|
| 133 |
+
bal = self._ensure_balance(session, player_id)
|
| 134 |
+
bal.balance += amount
|
| 135 |
+
bal.updated_at = datetime.utcnow()
|
| 136 |
+
txn = ClayTransaction(
|
| 137 |
+
player_id=player_id,
|
| 138 |
+
amount=amount,
|
| 139 |
+
balance_after=bal.balance,
|
| 140 |
+
reason=reason,
|
| 141 |
+
related_match_id=match_id,
|
| 142 |
+
created_at=datetime.utcnow(),
|
| 143 |
+
)
|
| 144 |
+
session.add(txn)
|
| 145 |
+
session.commit()
|
| 146 |
+
session.refresh(txn)
|
| 147 |
+
return txn
|
| 148 |
+
|
| 149 |
+
def transfer(
|
| 150 |
+
self,
|
| 151 |
+
from_id: str,
|
| 152 |
+
to_id: str,
|
| 153 |
+
amount: int,
|
| 154 |
+
reason: str,
|
| 155 |
+
match_id: str | None = None,
|
| 156 |
+
) -> tuple[ClayTransaction, ClayTransaction]:
|
| 157 |
+
"""Atomic two-sided transfer: debit from_id, credit to_id.
|
| 158 |
+
|
| 159 |
+
Either both succeed or neither does (single DB transaction).
|
| 160 |
+
"""
|
| 161 |
+
if amount <= 0:
|
| 162 |
+
raise ValueError(f"transfer amount must be positive, got {amount}")
|
| 163 |
+
with self._factory() as session:
|
| 164 |
+
from_bal = self._ensure_balance(session, from_id)
|
| 165 |
+
to_bal = self._ensure_balance(session, to_id)
|
| 166 |
+
if from_bal.balance < amount:
|
| 167 |
+
raise InsufficientFunds(
|
| 168 |
+
f"Balance {from_bal.balance} < required {amount} for player {from_id}"
|
| 169 |
+
)
|
| 170 |
+
from_bal.balance -= amount
|
| 171 |
+
from_bal.updated_at = datetime.utcnow()
|
| 172 |
+
to_bal.balance += amount
|
| 173 |
+
to_bal.updated_at = datetime.utcnow()
|
| 174 |
+
|
| 175 |
+
now = datetime.utcnow()
|
| 176 |
+
debit_txn = ClayTransaction(
|
| 177 |
+
player_id=from_id,
|
| 178 |
+
amount=-amount,
|
| 179 |
+
balance_after=from_bal.balance,
|
| 180 |
+
reason=reason,
|
| 181 |
+
related_match_id=match_id,
|
| 182 |
+
created_at=now,
|
| 183 |
+
)
|
| 184 |
+
credit_txn = ClayTransaction(
|
| 185 |
+
player_id=to_id,
|
| 186 |
+
amount=amount,
|
| 187 |
+
balance_after=to_bal.balance,
|
| 188 |
+
reason=reason,
|
| 189 |
+
related_match_id=match_id,
|
| 190 |
+
created_at=now,
|
| 191 |
+
)
|
| 192 |
+
session.add(debit_txn)
|
| 193 |
+
session.add(credit_txn)
|
| 194 |
+
session.commit()
|
| 195 |
+
session.refresh(debit_txn)
|
| 196 |
+
session.refresh(credit_txn)
|
| 197 |
+
return debit_txn, credit_txn
|
| 198 |
+
|
| 199 |
+
def transactions_for_player(
|
| 200 |
+
self,
|
| 201 |
+
player_id: str,
|
| 202 |
+
limit: int = 50,
|
| 203 |
+
reason: str | None = None,
|
| 204 |
+
) -> list[ClayTransaction]:
|
| 205 |
+
with self._factory() as session:
|
| 206 |
+
stmt = select(ClayTransaction).where(
|
| 207 |
+
ClayTransaction.player_id == player_id
|
| 208 |
+
)
|
| 209 |
+
if reason is not None:
|
| 210 |
+
stmt = stmt.where(ClayTransaction.reason == reason)
|
| 211 |
+
stmt = stmt.order_by(ClayTransaction.created_at.desc()).limit(limit)
|
| 212 |
+
return list(session.execute(stmt).scalars())
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# --- Module-level singleton -----------------------------------------------
|
| 216 |
+
# Initialized lazily on first call. Startup code in main.py calls get_ledger()
|
| 217 |
+
# once so any misconfiguration surfaces early.
|
| 218 |
+
|
| 219 |
+
_ledger: SqliteClayLedger | None = None
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def get_ledger() -> SqliteClayLedger:
|
| 223 |
+
global _ledger
|
| 224 |
+
if _ledger is None:
|
| 225 |
+
from app.db import SessionLocal
|
| 226 |
+
|
| 227 |
+
_ledger = SqliteClayLedger(SessionLocal)
|
| 228 |
+
return _ledger
|
app/main.py
CHANGED
|
@@ -54,6 +54,11 @@ async def lifespan(app: FastAPI):
|
|
| 54 |
configure_logging()
|
| 55 |
init_db()
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
# Capture the main event loop so the post-match processor's daemon thread
|
| 58 |
# can schedule Socket.IO emits back onto us via run_coroutine_threadsafe.
|
| 59 |
set_main_loop(asyncio.get_running_loop())
|
|
|
|
| 54 |
configure_logging()
|
| 55 |
init_db()
|
| 56 |
|
| 57 |
+
# Block 17: initialize the $CLAY ledger singleton so any misconfiguration
|
| 58 |
+
# surfaces at startup rather than on first wager.
|
| 59 |
+
from app.economy.clay_ledger import get_ledger as _get_ledger
|
| 60 |
+
_get_ledger()
|
| 61 |
+
|
| 62 |
# Capture the main event loop so the post-match processor's daemon thread
|
| 63 |
# can schedule Socket.IO emits back onto us via run_coroutine_threadsafe.
|
| 64 |
set_main_loop(asyncio.get_running_loop())
|
app/models/__init__.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
from app.models.auth import PasswordResetToken
|
| 2 |
from app.models.base import Base
|
|
|
|
|
|
|
| 3 |
from app.models.player_agent import PlayerAgent
|
| 4 |
from app.models.chat import (
|
| 5 |
CharacterChatSession,
|
|
@@ -31,6 +33,8 @@ from app.models.match import (
|
|
| 31 |
from app.models.memory import Memory, MemoryScope, MemoryType
|
| 32 |
|
| 33 |
__all__ = [
|
|
|
|
|
|
|
| 34 |
"PlayerAgent",
|
| 35 |
"Base",
|
| 36 |
"CharacterChatSession",
|
|
|
|
| 1 |
from app.models.auth import PasswordResetToken
|
| 2 |
from app.models.base import Base
|
| 3 |
+
from app.models.clay_balance import ClayBalance
|
| 4 |
+
from app.models.clay_transaction import ClayTransaction
|
| 5 |
from app.models.player_agent import PlayerAgent
|
| 6 |
from app.models.chat import (
|
| 7 |
CharacterChatSession,
|
|
|
|
| 33 |
from app.models.memory import Memory, MemoryScope, MemoryType
|
| 34 |
|
| 35 |
__all__ = [
|
| 36 |
+
"ClayBalance",
|
| 37 |
+
"ClayTransaction",
|
| 38 |
"PlayerAgent",
|
| 39 |
"Base",
|
| 40 |
"CharacterChatSession",
|
app/models/clay_balance.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
from sqlalchemy import DateTime, ForeignKey, Integer
|
| 6 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 7 |
+
|
| 8 |
+
from app.models.base import Base
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _now() -> datetime:
|
| 12 |
+
return datetime.utcnow()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ClayBalance(Base):
|
| 16 |
+
__tablename__ = "clay_balances"
|
| 17 |
+
|
| 18 |
+
player_id: Mapped[str] = mapped_column(
|
| 19 |
+
ForeignKey("players.id", ondelete="CASCADE"), primary_key=True
|
| 20 |
+
)
|
| 21 |
+
# Integer cents — avoids float precision issues. 100 $CLAY = 10000 stored.
|
| 22 |
+
balance: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
| 23 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 24 |
+
DateTime, nullable=False, default=_now, onupdate=_now
|
| 25 |
+
)
|
app/models/clay_transaction.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
| 7 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 8 |
+
|
| 9 |
+
from app.models.base import Base
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _uuid() -> str:
|
| 13 |
+
return str(uuid4())
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _now() -> datetime:
|
| 17 |
+
return datetime.utcnow()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ClayTransaction(Base):
|
| 21 |
+
"""Immutable audit log for every $CLAY balance movement."""
|
| 22 |
+
|
| 23 |
+
__tablename__ = "clay_transactions"
|
| 24 |
+
|
| 25 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
| 26 |
+
player_id: Mapped[str] = mapped_column(
|
| 27 |
+
ForeignKey("players.id", ondelete="CASCADE"), nullable=False, index=True
|
| 28 |
+
)
|
| 29 |
+
# Signed: negative = debit (money left), positive = credit (money arrived).
|
| 30 |
+
amount: Mapped[int] = mapped_column(Integer, nullable=False)
|
| 31 |
+
# Balance after this transaction — snapshot for audit.
|
| 32 |
+
balance_after: Mapped[int] = mapped_column(Integer, nullable=False)
|
| 33 |
+
# e.g. "starting_grant", "match_stake", "match_win", "match_loss",
|
| 34 |
+
# "match_draw_refund", "match_abandon_refund"
|
| 35 |
+
reason: Mapped[str] = mapped_column(String(64), nullable=False)
|
| 36 |
+
related_match_id: Mapped[str | None] = mapped_column(
|
| 37 |
+
ForeignKey("matches.id", ondelete="SET NULL"), nullable=True, index=True
|
| 38 |
+
)
|
| 39 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 40 |
+
DateTime, nullable=False, default=_now
|
| 41 |
+
)
|
app/models/match.py
CHANGED
|
@@ -179,6 +179,13 @@ class Match(Base):
|
|
| 179 |
|
| 180 |
extra_state: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=_now)
|
| 183 |
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
| 184 |
|
|
|
|
| 179 |
|
| 180 |
extra_state: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
| 181 |
|
| 182 |
+
# Block 17: $CLAY wagering. 0 = casual (no money moves). Integer cents.
|
| 183 |
+
stake_cents: Mapped[int] = mapped_column(
|
| 184 |
+
Integer, nullable=False, default=0, server_default="0"
|
| 185 |
+
)
|
| 186 |
+
# Timestamp set when wager is settled — prevents double-settlement on retry.
|
| 187 |
+
stake_settled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
| 188 |
+
|
| 189 |
started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=_now)
|
| 190 |
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
| 191 |
|
app/post_match/processor.py
CHANGED
|
@@ -35,6 +35,7 @@ from app.models.match import (
|
|
| 35 |
Match,
|
| 36 |
MatchAnalysis,
|
| 37 |
MatchAnalysisStatus,
|
|
|
|
| 38 |
MatchStatus,
|
| 39 |
Move,
|
| 40 |
OpponentProfile,
|
|
@@ -418,6 +419,15 @@ def _run_pipeline(
|
|
| 418 |
except Exception as exc:
|
| 419 |
logger.exception("elo_ratchet failed for match=%s: %s", match_id, exc)
|
| 420 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
# --- Step 3.5: Evolution (pure data, no LLM, private matches skipped) --
|
| 422 |
_start(STEP_EVOLUTION)
|
| 423 |
try:
|
|
@@ -518,6 +528,70 @@ def _run_pipeline(
|
|
| 518 |
# --- Internal helpers ------------------------------------------------------
|
| 519 |
|
| 520 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
def _get_or_create_profile(session: Session, character_id: str, player_id: str) -> OpponentProfile:
|
| 522 |
row = session.execute(
|
| 523 |
select(OpponentProfile).where(
|
|
|
|
| 35 |
Match,
|
| 36 |
MatchAnalysis,
|
| 37 |
MatchAnalysisStatus,
|
| 38 |
+
MatchResult,
|
| 39 |
MatchStatus,
|
| 40 |
Move,
|
| 41 |
OpponentProfile,
|
|
|
|
| 419 |
except Exception as exc:
|
| 420 |
logger.exception("elo_ratchet failed for match=%s: %s", match_id, exc)
|
| 421 |
|
| 422 |
+
# --- Step 3.6: $CLAY wager settlement (one-shot guard via stake_settled_at) ---
|
| 423 |
+
try:
|
| 424 |
+
with session_scope() as session:
|
| 425 |
+
match = session.get(Match, match_id)
|
| 426 |
+
if match and match.stake_cents > 0 and match.stake_settled_at is None:
|
| 427 |
+
_settle_wager(session, match)
|
| 428 |
+
except Exception as exc:
|
| 429 |
+
logger.exception("wager_settlement failed for match=%s: %s", match_id, exc)
|
| 430 |
+
|
| 431 |
# --- Step 3.5: Evolution (pure data, no LLM, private matches skipped) --
|
| 432 |
_start(STEP_EVOLUTION)
|
| 433 |
try:
|
|
|
|
| 528 |
# --- Internal helpers ------------------------------------------------------
|
| 529 |
|
| 530 |
|
| 531 |
+
def _settle_wager(session: Session, match: Match) -> None:
|
| 532 |
+
"""Settle the $CLAY wager for a match. Called at most once (one-shot guard).
|
| 533 |
+
|
| 534 |
+
Determines outcome relative to the player/agent (not the character), then:
|
| 535 |
+
Win → credit 2× stake (stake back + equal win).
|
| 536 |
+
Loss → no credit (stake already debited at match creation; log a marker txn).
|
| 537 |
+
Draw / Abandoned → refund the stake.
|
| 538 |
+
|
| 539 |
+
Sets match.stake_settled_at within the caller's session so the guard is
|
| 540 |
+
committed atomically with any credit/debit.
|
| 541 |
+
"""
|
| 542 |
+
from datetime import datetime
|
| 543 |
+
from app.economy.clay_ledger import get_ledger
|
| 544 |
+
|
| 545 |
+
stake = match.stake_cents
|
| 546 |
+
player_id = match.player_id
|
| 547 |
+
match_id = match.id
|
| 548 |
+
|
| 549 |
+
# Determine outcome from first principles (player_outcome() conflates
|
| 550 |
+
# RESIGNED and ABANDONED; we want to refund abandoned matches per spec).
|
| 551 |
+
if match.status == MatchStatus.ABANDONED:
|
| 552 |
+
raw_outcome = "abandoned"
|
| 553 |
+
elif match.status in (MatchStatus.COMPLETED, MatchStatus.RESIGNED):
|
| 554 |
+
if match.result == MatchResult.DRAW:
|
| 555 |
+
raw_outcome = "draw"
|
| 556 |
+
elif match.result == MatchResult.ABANDONED:
|
| 557 |
+
raw_outcome = "abandoned"
|
| 558 |
+
else:
|
| 559 |
+
from app.models.match import Color as _Color
|
| 560 |
+
player_side = match.player_color
|
| 561 |
+
winning_side = (
|
| 562 |
+
_Color.WHITE if match.result == MatchResult.WHITE_WIN else _Color.BLACK
|
| 563 |
+
)
|
| 564 |
+
raw_outcome = "win" if player_side == winning_side else "loss"
|
| 565 |
+
else:
|
| 566 |
+
logger.warning("_settle_wager: match %s is not terminal (status=%s)", match_id, match.status)
|
| 567 |
+
return
|
| 568 |
+
|
| 569 |
+
ledger = get_ledger()
|
| 570 |
+
|
| 571 |
+
if raw_outcome == "win":
|
| 572 |
+
ledger.credit(player_id, stake * 2, reason="match_win", match_id=match_id)
|
| 573 |
+
logger.info(
|
| 574 |
+
"wager settled: win player=%s match=%s +%d cents", player_id, match_id, stake * 2
|
| 575 |
+
)
|
| 576 |
+
elif raw_outcome in ("draw", "abandoned"):
|
| 577 |
+
ledger.credit(player_id, stake, reason="match_draw_refund", match_id=match_id)
|
| 578 |
+
logger.info(
|
| 579 |
+
"wager settled: %s player=%s match=%s refund %d cents",
|
| 580 |
+
raw_outcome, player_id, match_id, stake,
|
| 581 |
+
)
|
| 582 |
+
else:
|
| 583 |
+
# Loss — stake was already debited. Record an audit marker with amount=0
|
| 584 |
+
# so transaction history shows what happened.
|
| 585 |
+
ledger.credit(player_id, 0, reason="match_loss", match_id=match_id)
|
| 586 |
+
logger.info(
|
| 587 |
+
"wager settled: loss player=%s match=%s (stake %d forfeited)",
|
| 588 |
+
player_id, match_id, stake,
|
| 589 |
+
)
|
| 590 |
+
|
| 591 |
+
match.stake_settled_at = datetime.utcnow()
|
| 592 |
+
session.flush()
|
| 593 |
+
|
| 594 |
+
|
| 595 |
def _get_or_create_profile(session: Session, character_id: str, player_id: str) -> OpponentProfile:
|
| 596 |
row = session.execute(
|
| 597 |
select(OpponentProfile).where(
|
app/web/routes.py
CHANGED
|
@@ -54,6 +54,7 @@ from app.characters.chat_service import (
|
|
| 54 |
from app.characters.openings import OPENINGS
|
| 55 |
from app.characters.rooms import theme_for_character
|
| 56 |
from app.characters.style import style_to_prompt_fragments
|
|
|
|
| 57 |
from app.db import get_session
|
| 58 |
from app.discovery import (
|
| 59 |
character_leaderboard,
|
|
@@ -82,9 +83,42 @@ logger = logging.getLogger(__name__)
|
|
| 82 |
_TEMPLATE_DIR = Path(__file__).parent / "templates"
|
| 83 |
templates = Jinja2Templates(directory=str(_TEMPLATE_DIR))
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
router = APIRouter(tags=["web"])
|
| 86 |
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
def _room_for_match(character) -> "RoomTheme":
|
| 89 |
"""Return the room theme for a match page, swapping in ambient_track_game
|
| 90 |
and ambient_volume_game when the character has a separate in-game track."""
|
|
@@ -516,6 +550,9 @@ def signup_submit(
|
|
| 516 |
session.commit()
|
| 517 |
session.refresh(final_player)
|
| 518 |
flash_key = "welcome"
|
|
|
|
|
|
|
|
|
|
| 519 |
|
| 520 |
safe_next = _safe_next(next)
|
| 521 |
separator = "&" if "?" in safe_next else "?"
|
|
@@ -1600,17 +1637,38 @@ def agent_detail_page(
|
|
| 1600 |
recent_matches = []
|
| 1601 |
for m in recent_matches_rows:
|
| 1602 |
char = session.get(Character, m.character_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1603 |
recent_matches.append({
|
| 1604 |
"id": m.id,
|
| 1605 |
"character_name": char.name if char else "Unknown",
|
| 1606 |
"character_emoji": char.avatar_emoji if char else "♟",
|
| 1607 |
"status": m.status.value,
|
| 1608 |
"result": m.result.value if m.result else None,
|
|
|
|
| 1609 |
"player_color": m.player_color.value,
|
| 1610 |
"created_at": m.started_at,
|
| 1611 |
"move_count": m.move_count,
|
|
|
|
|
|
|
| 1612 |
})
|
| 1613 |
|
|
|
|
|
|
|
|
|
|
| 1614 |
return templates.TemplateResponse(
|
| 1615 |
request,
|
| 1616 |
"agent_detail.html",
|
|
@@ -1626,6 +1684,8 @@ def agent_detail_page(
|
|
| 1626 |
"name_max": _AGENT_NAME_MAX,
|
| 1627 |
"recent_matches": recent_matches,
|
| 1628 |
"total_match_count": total_match_count,
|
|
|
|
|
|
|
| 1629 |
},
|
| 1630 |
)
|
| 1631 |
|
|
@@ -1734,6 +1794,135 @@ def agent_room_page(
|
|
| 1734 |
)
|
| 1735 |
|
| 1736 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1737 |
@router.get("/matches/{match_id}/summary", response_class=HTMLResponse)
|
| 1738 |
def match_summary_page(
|
| 1739 |
request: Request,
|
|
|
|
| 54 |
from app.characters.openings import OPENINGS
|
| 55 |
from app.characters.rooms import theme_for_character
|
| 56 |
from app.characters.style import style_to_prompt_fragments
|
| 57 |
+
from app.config import get_settings
|
| 58 |
from app.db import get_session
|
| 59 |
from app.discovery import (
|
| 60 |
character_leaderboard,
|
|
|
|
| 83 |
_TEMPLATE_DIR = Path(__file__).parent / "templates"
|
| 84 |
templates = Jinja2Templates(directory=str(_TEMPLATE_DIR))
|
| 85 |
|
| 86 |
+
|
| 87 |
+
def _clay_balance_global(player_id: str) -> int:
|
| 88 |
+
"""Jinja2 global: {{ clay_balance(player.id) }} in any template."""
|
| 89 |
+
from app.economy.clay_ledger import get_ledger
|
| 90 |
+
try:
|
| 91 |
+
return get_ledger().get_balance(player_id)
|
| 92 |
+
except Exception:
|
| 93 |
+
return 0
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
templates.env.globals["clay_balance"] = _clay_balance_global
|
| 97 |
+
|
| 98 |
router = APIRouter(tags=["web"])
|
| 99 |
|
| 100 |
|
| 101 |
+
def _grant_starting_clay(player_id: str) -> None:
|
| 102 |
+
"""Credit the starting $CLAY grant exactly once per player.
|
| 103 |
+
|
| 104 |
+
Idempotent: checks for an existing 'starting_grant' transaction before
|
| 105 |
+
crediting. Safe to call from signup and from the backfill script.
|
| 106 |
+
"""
|
| 107 |
+
from app.config import get_settings
|
| 108 |
+
from app.economy.clay_ledger import get_ledger
|
| 109 |
+
|
| 110 |
+
ledger = get_ledger()
|
| 111 |
+
existing = ledger.transactions_for_player(player_id, limit=1, reason="starting_grant")
|
| 112 |
+
if existing:
|
| 113 |
+
return
|
| 114 |
+
amount = get_settings().starting_clay_grant
|
| 115 |
+
try:
|
| 116 |
+
ledger.credit(player_id, amount, reason="starting_grant")
|
| 117 |
+
logger.info("Granted %d cents starting $CLAY to player %s", amount, player_id)
|
| 118 |
+
except Exception:
|
| 119 |
+
logger.exception("Failed to grant starting $CLAY to player %s", player_id)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
def _room_for_match(character) -> "RoomTheme":
|
| 123 |
"""Return the room theme for a match page, swapping in ambient_track_game
|
| 124 |
and ambient_volume_game when the character has a separate in-game track."""
|
|
|
|
| 550 |
session.commit()
|
| 551 |
session.refresh(final_player)
|
| 552 |
flash_key = "welcome"
|
| 553 |
+
# Grant starting $CLAY to new players (idempotent — skipped if already
|
| 554 |
+
# granted, which guards the guest-upgrade path from double-granting).
|
| 555 |
+
_grant_starting_clay(final_player.id)
|
| 556 |
|
| 557 |
safe_next = _safe_next(next)
|
| 558 |
separator = "&" if "?" in safe_next else "?"
|
|
|
|
| 1637 |
recent_matches = []
|
| 1638 |
for m in recent_matches_rows:
|
| 1639 |
char = session.get(Character, m.character_id)
|
| 1640 |
+
# Determine agent outcome for display.
|
| 1641 |
+
agent_won = None
|
| 1642 |
+
if m.result is not None:
|
| 1643 |
+
from app.models.match import MatchResult as _MR, Color as _C
|
| 1644 |
+
agent_is_white = m.player_color == _C.WHITE
|
| 1645 |
+
if m.result == _MR.DRAW:
|
| 1646 |
+
agent_won = "draw"
|
| 1647 |
+
elif m.result == _MR.ABANDONED:
|
| 1648 |
+
agent_won = "abandoned"
|
| 1649 |
+
elif (m.result == _MR.WHITE_WIN and agent_is_white) or (
|
| 1650 |
+
m.result == _MR.BLACK_WIN and not agent_is_white
|
| 1651 |
+
):
|
| 1652 |
+
agent_won = "win"
|
| 1653 |
+
else:
|
| 1654 |
+
agent_won = "loss"
|
| 1655 |
recent_matches.append({
|
| 1656 |
"id": m.id,
|
| 1657 |
"character_name": char.name if char else "Unknown",
|
| 1658 |
"character_emoji": char.avatar_emoji if char else "♟",
|
| 1659 |
"status": m.status.value,
|
| 1660 |
"result": m.result.value if m.result else None,
|
| 1661 |
+
"agent_won": agent_won,
|
| 1662 |
"player_color": m.player_color.value,
|
| 1663 |
"created_at": m.started_at,
|
| 1664 |
"move_count": m.move_count,
|
| 1665 |
+
"stake_cents": m.stake_cents,
|
| 1666 |
+
"stake_settled": m.stake_settled_at is not None,
|
| 1667 |
})
|
| 1668 |
|
| 1669 |
+
from app.economy.clay_ledger import get_ledger as _get_ledger
|
| 1670 |
+
player_clay_balance = _get_ledger().get_balance(player.id)
|
| 1671 |
+
|
| 1672 |
return templates.TemplateResponse(
|
| 1673 |
request,
|
| 1674 |
"agent_detail.html",
|
|
|
|
| 1684 |
"name_max": _AGENT_NAME_MAX,
|
| 1685 |
"recent_matches": recent_matches,
|
| 1686 |
"total_match_count": total_match_count,
|
| 1687 |
+
"player_clay_balance": player_clay_balance,
|
| 1688 |
+
"max_stake_display": get_settings().max_stake_cents // 100,
|
| 1689 |
},
|
| 1690 |
)
|
| 1691 |
|
|
|
|
| 1794 |
)
|
| 1795 |
|
| 1796 |
|
| 1797 |
+
@router.post("/agents/{agent_id}/play-kenji")
|
| 1798 |
+
async def send_agent_to_play_kenji(
|
| 1799 |
+
agent_id: str,
|
| 1800 |
+
request: Request,
|
| 1801 |
+
stake_display: int = Form(0),
|
| 1802 |
+
player: Player = Depends(require_player),
|
| 1803 |
+
session: Session = Depends(get_session),
|
| 1804 |
+
) -> RedirectResponse:
|
| 1805 |
+
"""Direct dispatch: send agent to play Kenji with an optional $CLAY stake.
|
| 1806 |
+
|
| 1807 |
+
Bypasses the briefing room — creates the match, debits the stake, and
|
| 1808 |
+
starts the agent loop. On validation failure, redirects back to agent detail
|
| 1809 |
+
with an error query param.
|
| 1810 |
+
"""
|
| 1811 |
+
import asyncio as _asyncio
|
| 1812 |
+
from app.economy.clay_ledger import InsufficientFunds, get_ledger
|
| 1813 |
+
from app.matches.service import create_agent_match
|
| 1814 |
+
from app.matches.agent_streaming import run_agent_match_loop
|
| 1815 |
+
from app.models.player_agent import PlayerAgent
|
| 1816 |
+
|
| 1817 |
+
agent = session.get(PlayerAgent, agent_id)
|
| 1818 |
+
if agent is None or agent.archived_at is not None:
|
| 1819 |
+
raise HTTPException(status_code=404, detail="Agent not found")
|
| 1820 |
+
if agent.owner_player_id != player.id:
|
| 1821 |
+
raise HTTPException(status_code=403, detail="Not your agent.")
|
| 1822 |
+
|
| 1823 |
+
settings = get_settings()
|
| 1824 |
+
stake_cents = max(0, stake_display) * 100
|
| 1825 |
+
|
| 1826 |
+
if stake_cents > 0:
|
| 1827 |
+
max_stake = settings.max_stake_cents
|
| 1828 |
+
if stake_cents > max_stake:
|
| 1829 |
+
return RedirectResponse(
|
| 1830 |
+
url=f"/agents/{agent_id}?error=stake_too_large", status_code=303
|
| 1831 |
+
)
|
| 1832 |
+
ledger = get_ledger()
|
| 1833 |
+
balance = ledger.get_balance(player.id)
|
| 1834 |
+
if balance < stake_cents:
|
| 1835 |
+
return RedirectResponse(
|
| 1836 |
+
url=f"/agents/{agent_id}?error=stake_insufficient", status_code=303
|
| 1837 |
+
)
|
| 1838 |
+
|
| 1839 |
+
from app.engine import EngineUnavailable
|
| 1840 |
+
try:
|
| 1841 |
+
match = create_agent_match(
|
| 1842 |
+
session,
|
| 1843 |
+
agent_id=agent_id,
|
| 1844 |
+
player_id=player.id,
|
| 1845 |
+
character_preset_key="kenji_sato",
|
| 1846 |
+
)
|
| 1847 |
+
match.stake_cents = stake_cents
|
| 1848 |
+
session.commit()
|
| 1849 |
+
session.refresh(match)
|
| 1850 |
+
except EngineUnavailable as exc:
|
| 1851 |
+
session.rollback()
|
| 1852 |
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 1853 |
+
except Exception as exc:
|
| 1854 |
+
session.rollback()
|
| 1855 |
+
logger.exception("send_agent_to_play_kenji: match creation failed")
|
| 1856 |
+
raise HTTPException(status_code=500, detail="Could not create match") from exc
|
| 1857 |
+
|
| 1858 |
+
if stake_cents > 0:
|
| 1859 |
+
try:
|
| 1860 |
+
get_ledger().debit(
|
| 1861 |
+
player.id,
|
| 1862 |
+
stake_cents,
|
| 1863 |
+
reason="match_stake",
|
| 1864 |
+
match_id=match.id,
|
| 1865 |
+
)
|
| 1866 |
+
except InsufficientFunds:
|
| 1867 |
+
# Race: balance changed between check and debit — refund path: no stake.
|
| 1868 |
+
match.stake_cents = 0
|
| 1869 |
+
session.commit()
|
| 1870 |
+
logger.warning(
|
| 1871 |
+
"stake debit race for player=%s match=%s — cleared stake",
|
| 1872 |
+
player.id, match.id,
|
| 1873 |
+
)
|
| 1874 |
+
|
| 1875 |
+
_asyncio.create_task(
|
| 1876 |
+
run_agent_match_loop(match.id, agent_id),
|
| 1877 |
+
name=f"agent-loop-{match.id}",
|
| 1878 |
+
)
|
| 1879 |
+
return RedirectResponse(url=f"/matches/{match.id}", status_code=303)
|
| 1880 |
+
|
| 1881 |
+
|
| 1882 |
+
@router.get("/me/clay-history", response_class=HTMLResponse)
|
| 1883 |
+
def clay_history_page(
|
| 1884 |
+
request: Request,
|
| 1885 |
+
player: Player = Depends(require_player),
|
| 1886 |
+
page: int = 1,
|
| 1887 |
+
) -> HTMLResponse:
|
| 1888 |
+
from app.economy.clay_ledger import get_ledger
|
| 1889 |
+
from app.models.clay_transaction import ClayTransaction
|
| 1890 |
+
|
| 1891 |
+
per_page = 30
|
| 1892 |
+
offset_n = (max(1, page) - 1) * per_page
|
| 1893 |
+
ledger = get_ledger()
|
| 1894 |
+
|
| 1895 |
+
# Fetch one extra to know if there is a next page.
|
| 1896 |
+
with ledger._factory() as session:
|
| 1897 |
+
from sqlalchemy import select as _select
|
| 1898 |
+
|
| 1899 |
+
stmt = (
|
| 1900 |
+
_select(ClayTransaction)
|
| 1901 |
+
.where(ClayTransaction.player_id == player.id)
|
| 1902 |
+
.order_by(ClayTransaction.created_at.desc())
|
| 1903 |
+
.offset(offset_n)
|
| 1904 |
+
.limit(per_page + 1)
|
| 1905 |
+
)
|
| 1906 |
+
rows = list(session.execute(stmt).scalars())
|
| 1907 |
+
|
| 1908 |
+
has_next = len(rows) > per_page
|
| 1909 |
+
transactions = rows[:per_page]
|
| 1910 |
+
balance = ledger.get_balance(player.id)
|
| 1911 |
+
|
| 1912 |
+
return templates.TemplateResponse(
|
| 1913 |
+
request,
|
| 1914 |
+
"clay_history.html",
|
| 1915 |
+
{
|
| 1916 |
+
"player": player,
|
| 1917 |
+
"transactions": transactions,
|
| 1918 |
+
"balance": balance,
|
| 1919 |
+
"page": page,
|
| 1920 |
+
"has_next": has_next,
|
| 1921 |
+
"per_page": per_page,
|
| 1922 |
+
},
|
| 1923 |
+
)
|
| 1924 |
+
|
| 1925 |
+
|
| 1926 |
@router.get("/matches/{match_id}/summary", response_class=HTMLResponse)
|
| 1927 |
def match_summary_page(
|
| 1928 |
request: Request,
|
app/web/templates/_summary_body.html
CHANGED
|
@@ -75,6 +75,23 @@
|
|
| 75 |
</div>
|
| 76 |
</div>
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
{% if elo_breakdown %}
|
| 79 |
<div class="mt-6 pt-5 border-t border-[var(--mp-hairline)]">
|
| 80 |
<div class="mp-eyebrow mb-3">How the delta was computed</div>
|
|
|
|
| 75 |
</div>
|
| 76 |
</div>
|
| 77 |
|
| 78 |
+
{# $CLAY wager outcome — only shown when there was a stake #}
|
| 79 |
+
{% if match.stake_cents > 0 %}
|
| 80 |
+
<div class="mt-5 pt-4 border-t border-[var(--mp-hairline)] flex items-center gap-4">
|
| 81 |
+
<div class="mp-eyebrow" style="color:var(--mp-brass-dim);">$CLAY Wager</div>
|
| 82 |
+
{% if player_outcome == 'win' %}
|
| 83 |
+
<span class="mp-mono text-[var(--mp-felt-bright)] text-[16px]">+{{ "%.0f"|format(match.stake_cents / 100) }} $CLAY</span>
|
| 84 |
+
<span class="text-[11px] text-[var(--mp-ink-faint)]">won the stake</span>
|
| 85 |
+
{% elif player_outcome == 'loss' %}
|
| 86 |
+
<span class="mp-mono text-[#E79E9B] text-[16px]">−{{ "%.0f"|format(match.stake_cents / 100) }} $CLAY</span>
|
| 87 |
+
<span class="text-[11px] text-[var(--mp-ink-faint)]">stake lost</span>
|
| 88 |
+
{% else %}
|
| 89 |
+
<span class="mp-mono text-[var(--mp-brass)] text-[16px]">{{ "%.0f"|format(match.stake_cents / 100) }} $CLAY</span>
|
| 90 |
+
<span class="text-[11px] text-[var(--mp-ink-faint)]">stake refunded (draw / abandoned)</span>
|
| 91 |
+
{% endif %}
|
| 92 |
+
</div>
|
| 93 |
+
{% endif %}
|
| 94 |
+
|
| 95 |
{% if elo_breakdown %}
|
| 96 |
<div class="mt-6 pt-5 border-t border-[var(--mp-hairline)]">
|
| 97 |
<div class="mp-eyebrow mb-3">How the delta was computed</div>
|
app/web/templates/agent_detail.html
CHANGED
|
@@ -87,6 +87,46 @@
|
|
| 87 |
<a href="/agents/{{ agent.id }}/room" class="mp-btn mp-btn-brass w-full text-center block">Enter Briefing Room</a>
|
| 88 |
</div>
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
{# Recent matches #}
|
| 91 |
<div class="mp-panel rounded-sm p-4">
|
| 92 |
<div class="mp-eyebrow mb-3">Recent Matches</div>
|
|
@@ -106,22 +146,21 @@
|
|
| 106 |
<div class="text-right shrink-0">
|
| 107 |
{% if m.status == 'in_progress' %}
|
| 108 |
<span class="text-[11px] text-[var(--mp-felt-bright)] mp-mono">live</span>
|
| 109 |
-
{% elif m.
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
{%
|
| 113 |
-
<span class="text-[11px] text-[#E79E9B] mp-mono">loss</span>
|
| 114 |
{% endif %}
|
| 115 |
-
{% elif m.
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
{%
|
| 119 |
-
<span class="text-[11px] text-[#E79E9B] mp-mono">loss</span>
|
| 120 |
{% endif %}
|
| 121 |
-
{% elif m.
|
| 122 |
<span class="text-[11px] text-[var(--mp-brass)] mp-mono">draw</span>
|
| 123 |
-
|
| 124 |
-
<
|
|
|
|
| 125 |
{% else %}
|
| 126 |
<span class="text-[11px] text-[var(--mp-ink-faint)] mp-mono">—</span>
|
| 127 |
{% endif %}
|
|
|
|
| 87 |
<a href="/agents/{{ agent.id }}/room" class="mp-btn mp-btn-brass w-full text-center block">Enter Briefing Room</a>
|
| 88 |
</div>
|
| 89 |
|
| 90 |
+
{# Challenge Kenji — quick dispatch with optional $CLAY wager #}
|
| 91 |
+
<div class="mp-panel rounded-sm p-4 mp-framed">
|
| 92 |
+
<div class="mp-frame-tl"></div>
|
| 93 |
+
<div class="mp-frame-br"></div>
|
| 94 |
+
<div class="mp-eyebrow mb-3">Challenge Kenji</div>
|
| 95 |
+
|
| 96 |
+
{% if error == 'stake_insufficient' %}
|
| 97 |
+
<div class="mb-3 text-[11px] text-[#E79E9B] mp-mono">Not enough $CLAY for that stake.</div>
|
| 98 |
+
{% elif error == 'stake_too_large' %}
|
| 99 |
+
<div class="mb-3 text-[11px] text-[#E79E9B] mp-mono">Max stake is {{ max_stake_display }} $CLAY per match.</div>
|
| 100 |
+
{% elif error == 'stake_negative' %}
|
| 101 |
+
<div class="mb-3 text-[11px] text-[#E79E9B] mp-mono">Stake must be 0 or greater.</div>
|
| 102 |
+
{% endif %}
|
| 103 |
+
|
| 104 |
+
<form action="/agents/{{ agent.id }}/play-kenji" method="post" class="space-y-3">
|
| 105 |
+
<div>
|
| 106 |
+
<label class="block mp-eyebrow mb-1">Stake $CLAY</label>
|
| 107 |
+
<div class="flex items-center gap-2">
|
| 108 |
+
<input
|
| 109 |
+
type="number"
|
| 110 |
+
name="stake_display"
|
| 111 |
+
id="stake-input"
|
| 112 |
+
value="0"
|
| 113 |
+
min="0"
|
| 114 |
+
max="{{ [max_stake_display, player_clay_balance // 100] | min }}"
|
| 115 |
+
step="1"
|
| 116 |
+
class="mp-input w-28 text-center mp-mono"
|
| 117 |
+
/>
|
| 118 |
+
<span class="text-[11px] text-[var(--mp-ink-faint)] mp-mono">
|
| 119 |
+
/ {{ "%.2f"|format(player_clay_balance / 100) }} available
|
| 120 |
+
</span>
|
| 121 |
+
</div>
|
| 122 |
+
<div class="text-[10px] text-[var(--mp-ink-faint)] mt-1">0 = casual (no balance change) · max {{ max_stake_display }} $CLAY</div>
|
| 123 |
+
</div>
|
| 124 |
+
<button type="submit" class="mp-btn mp-btn-brass w-full">
|
| 125 |
+
Send {{ agent.name }} to play Kenji
|
| 126 |
+
</button>
|
| 127 |
+
</form>
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
{# Recent matches #}
|
| 131 |
<div class="mp-panel rounded-sm p-4">
|
| 132 |
<div class="mp-eyebrow mb-3">Recent Matches</div>
|
|
|
|
| 146 |
<div class="text-right shrink-0">
|
| 147 |
{% if m.status == 'in_progress' %}
|
| 148 |
<span class="text-[11px] text-[var(--mp-felt-bright)] mp-mono">live</span>
|
| 149 |
+
{% elif m.agent_won == 'win' %}
|
| 150 |
+
<span class="text-[11px] text-[var(--mp-felt-bright)] mp-mono">win</span>
|
| 151 |
+
{% if m.stake_cents > 0 %}
|
| 152 |
+
<div class="text-[10px] text-[var(--mp-felt-bright)] mp-mono">+{{ "%.0f"|format(m.stake_cents / 100) }} $CLAY</div>
|
|
|
|
| 153 |
{% endif %}
|
| 154 |
+
{% elif m.agent_won == 'loss' %}
|
| 155 |
+
<span class="text-[11px] text-[#E79E9B] mp-mono">loss</span>
|
| 156 |
+
{% if m.stake_cents > 0 %}
|
| 157 |
+
<div class="text-[10px] text-[#E79E9B] mp-mono">-{{ "%.0f"|format(m.stake_cents / 100) }} $CLAY</div>
|
|
|
|
| 158 |
{% endif %}
|
| 159 |
+
{% elif m.agent_won == 'draw' %}
|
| 160 |
<span class="text-[11px] text-[var(--mp-brass)] mp-mono">draw</span>
|
| 161 |
+
{% if m.stake_cents > 0 %}
|
| 162 |
+
<div class="text-[10px] text-[var(--mp-brass)] mp-mono">refunded</div>
|
| 163 |
+
{% endif %}
|
| 164 |
{% else %}
|
| 165 |
<span class="text-[11px] text-[var(--mp-ink-faint)] mp-mono">—</span>
|
| 166 |
{% endif %}
|
app/web/templates/base.html
CHANGED
|
@@ -400,10 +400,21 @@
|
|
| 400 |
<span aria-hidden="true">🔇</span>
|
| 401 |
</button>
|
| 402 |
{% if player %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
<details class="mp-user-menu">
|
| 404 |
<summary title="Account menu">@{{ player.username }}</summary>
|
| 405 |
<div class="mp-user-menu-panel">
|
| 406 |
<a href="/agents">My Agents</a>
|
|
|
|
| 407 |
<a href="/settings">Settings</a>
|
| 408 |
<a href="/logout">Logout</a>
|
| 409 |
</div>
|
|
|
|
| 400 |
<span aria-hidden="true">🔇</span>
|
| 401 |
</button>
|
| 402 |
{% if player %}
|
| 403 |
+
{# $CLAY balance — brass tone, links to history page. #}
|
| 404 |
+
{%- set _balance_cents = clay_balance(player.id) -%}
|
| 405 |
+
<a href="/me/clay-history"
|
| 406 |
+
class="mp-clay-display flex items-center gap-1 transition hover:opacity-90"
|
| 407 |
+
title="$CLAY balance — click for transaction history"
|
| 408 |
+
style="font-family: 'IBM Plex Mono', monospace; font-size: 12px; letter-spacing: 0.04em; text-decoration: none;
|
| 409 |
+
color: {% if _balance_cents < 100 %}var(--mp-ink-ghost){% else %}var(--mp-brass){% endif %};"
|
| 410 |
+
{% if _balance_cents < 100 %}data-tooltip="Balance low — top up via Metropolis (coming soon)"{% endif %}>
|
| 411 |
+
{{ "%.2f"|format(_balance_cents / 100) }} <span style="opacity:0.65;">$CLAY</span>
|
| 412 |
+
</a>
|
| 413 |
<details class="mp-user-menu">
|
| 414 |
<summary title="Account menu">@{{ player.username }}</summary>
|
| 415 |
<div class="mp-user-menu-panel">
|
| 416 |
<a href="/agents">My Agents</a>
|
| 417 |
+
<a href="/me/clay-history">$CLAY History</a>
|
| 418 |
<a href="/settings">Settings</a>
|
| 419 |
<a href="/logout">Logout</a>
|
| 420 |
</div>
|
app/web/templates/clay_history.html
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}$CLAY History — Metropolis Chess Club{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="mp-enter mp-enter-1 mb-2">
|
| 7 |
+
<a href="/" class="text-[12px] text-[var(--mp-ink-faint)] hover:text-[var(--mp-ink-muted)] transition">← Back</a>
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<div class="mp-enter mp-enter-1 mb-6 flex items-end justify-between">
|
| 11 |
+
<div>
|
| 12 |
+
<div class="mp-eyebrow mb-2">$CLAY Economy</div>
|
| 13 |
+
<h1 class="mp-display text-[28px] tracking-tight">Transaction History</h1>
|
| 14 |
+
</div>
|
| 15 |
+
<div class="text-right">
|
| 16 |
+
<div class="mp-eyebrow mb-1">Current Balance</div>
|
| 17 |
+
<div class="mp-mono text-[26px]
|
| 18 |
+
{% if balance < 100 %}text-[var(--mp-ink-faint)]{% else %}text-[var(--mp-brass)]{% endif %}">
|
| 19 |
+
{{ "%.2f"|format(balance / 100) }} $CLAY
|
| 20 |
+
</div>
|
| 21 |
+
{% if balance < 100 %}
|
| 22 |
+
<div class="text-[11px] text-[var(--mp-ink-faint)] mt-1 mp-mono">
|
| 23 |
+
Balance low — top up via Metropolis (coming soon)
|
| 24 |
+
</div>
|
| 25 |
+
{% endif %}
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<div class="mp-panel rounded-sm overflow-hidden mp-enter mp-enter-2">
|
| 30 |
+
{% if transactions %}
|
| 31 |
+
<table class="w-full text-[13px]">
|
| 32 |
+
<thead>
|
| 33 |
+
<tr class="border-b border-[var(--mp-hairline)] bg-[var(--mp-surface-2)]">
|
| 34 |
+
<th class="text-left px-5 py-3 mp-eyebrow font-normal">Date</th>
|
| 35 |
+
<th class="text-left px-5 py-3 mp-eyebrow font-normal">Reason</th>
|
| 36 |
+
<th class="text-right px-5 py-3 mp-eyebrow font-normal">Amount</th>
|
| 37 |
+
<th class="text-right px-5 py-3 mp-eyebrow font-normal">Balance</th>
|
| 38 |
+
<th class="text-left px-5 py-3 mp-eyebrow font-normal">Match</th>
|
| 39 |
+
</tr>
|
| 40 |
+
</thead>
|
| 41 |
+
<tbody>
|
| 42 |
+
{% for txn in transactions %}
|
| 43 |
+
<tr class="border-b border-[var(--mp-hairline)] hover:bg-[var(--mp-surface-2)] transition">
|
| 44 |
+
<td class="px-5 py-3 text-[var(--mp-ink-faint)] mp-mono text-[11px] whitespace-nowrap">
|
| 45 |
+
{{ txn.created_at.strftime('%Y-%m-%d %H:%M') }}
|
| 46 |
+
</td>
|
| 47 |
+
<td class="px-5 py-3 text-[var(--mp-ink-muted)] mp-mono text-[11px]">
|
| 48 |
+
{{ txn.reason | replace("_", " ") }}
|
| 49 |
+
</td>
|
| 50 |
+
<td class="px-5 py-3 text-right mp-mono text-[13px] font-medium whitespace-nowrap
|
| 51 |
+
{% if txn.amount > 0 %}text-[var(--mp-felt-bright)]
|
| 52 |
+
{% elif txn.amount < 0 %}text-[#E79E9B]
|
| 53 |
+
{% else %}text-[var(--mp-ink-faint)]{% endif %}">
|
| 54 |
+
{% if txn.amount > 0 %}+{{ "%.2f"|format(txn.amount / 100) }}
|
| 55 |
+
{% elif txn.amount < 0 %}{{ "%.2f"|format(txn.amount / 100) }}
|
| 56 |
+
{% else %}<span class="text-[var(--mp-ink-faint)]">—</span>{% endif %}
|
| 57 |
+
</td>
|
| 58 |
+
<td class="px-5 py-3 text-right mp-mono text-[12px] text-[var(--mp-ink-muted)] whitespace-nowrap">
|
| 59 |
+
{{ "%.2f"|format(txn.balance_after / 100) }}
|
| 60 |
+
</td>
|
| 61 |
+
<td class="px-5 py-3">
|
| 62 |
+
{% if txn.related_match_id %}
|
| 63 |
+
<a href="/matches/{{ txn.related_match_id }}"
|
| 64 |
+
class="text-[11px] text-[var(--mp-ink-blue-alt)] hover:text-[var(--mp-ink-blue)] transition mp-mono">
|
| 65 |
+
view match →
|
| 66 |
+
</a>
|
| 67 |
+
{% else %}
|
| 68 |
+
<span class="text-[var(--mp-ink-ghost)] text-[11px] mp-mono">—</span>
|
| 69 |
+
{% endif %}
|
| 70 |
+
</td>
|
| 71 |
+
</tr>
|
| 72 |
+
{% endfor %}
|
| 73 |
+
</tbody>
|
| 74 |
+
</table>
|
| 75 |
+
|
| 76 |
+
{# Pagination #}
|
| 77 |
+
<div class="flex items-center justify-between px-5 py-4 border-t border-[var(--mp-hairline)]">
|
| 78 |
+
<div class="text-[11px] text-[var(--mp-ink-faint)] mp-mono">
|
| 79 |
+
Page {{ page }} · {{ transactions | length }} rows
|
| 80 |
+
</div>
|
| 81 |
+
<div class="flex gap-3">
|
| 82 |
+
{% if page > 1 %}
|
| 83 |
+
<a href="/me/clay-history?page={{ page - 1 }}" class="mp-btn mp-btn-ghost text-[12px]">← Prev</a>
|
| 84 |
+
{% endif %}
|
| 85 |
+
{% if has_next %}
|
| 86 |
+
<a href="/me/clay-history?page={{ page + 1 }}" class="mp-btn mp-btn-ghost text-[12px]">Next →</a>
|
| 87 |
+
{% endif %}
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
{% else %}
|
| 92 |
+
<div class="py-16 text-center">
|
| 93 |
+
<div class="mp-display italic text-[var(--mp-ink-muted)] text-[15px]">No transactions yet.</div>
|
| 94 |
+
<div class="text-[12px] text-[var(--mp-ink-faint)] mt-2">
|
| 95 |
+
Sign up grants you 100 $CLAY. Send an agent to play Kenji to start wagering.
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
{% endif %}
|
| 99 |
+
</div>
|
| 100 |
+
{% endblock %}
|
app/web/templates/play.html
CHANGED
|
@@ -125,6 +125,15 @@
|
|
| 125 |
</div>
|
| 126 |
</div>
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,500px)_minmax(0,1fr)] gap-6 mp-enter mp-enter-2">
|
| 129 |
<div>
|
| 130 |
{% if room and room.emotion_clips %}
|
|
@@ -881,6 +890,23 @@
|
|
| 881 |
tail = outcome === 'won' ? ` — ${AGENT_NAME} won` : outcome === 'lost' ? ` — ${AGENT_NAME} lost` : ` — ${outcome}`;
|
| 882 |
}
|
| 883 |
resultEl.textContent = `${result || 'unknown'}${tail}${reason ? ` · ${reason}` : ''}`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 884 |
}
|
| 885 |
|
| 886 |
|
|
|
|
| 125 |
</div>
|
| 126 |
</div>
|
| 127 |
|
| 128 |
+
{% if match.stake_cents > 0 %}
|
| 129 |
+
<div class="mb-4 rounded-sm border border-[var(--mp-brass-dim)] bg-[rgba(201,166,107,0.06)] px-5 py-3 flex items-center gap-3 mp-enter mp-enter-2">
|
| 130 |
+
<span class="mp-eyebrow" style="color:var(--mp-brass-dim);">Wager</span>
|
| 131 |
+
<span class="mp-mono text-[var(--mp-brass)] text-[15px]">{{ "%.0f"|format(match.stake_cents / 100) }} $CLAY</span>
|
| 132 |
+
<span class="text-[11px] text-[var(--mp-ink-faint)]">·</span>
|
| 133 |
+
<span id="wager-outcome-label" class="text-[12px] text-[var(--mp-ink-muted)] mp-mono">in play</span>
|
| 134 |
+
</div>
|
| 135 |
+
{% endif %}
|
| 136 |
+
|
| 137 |
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,500px)_minmax(0,1fr)] gap-6 mp-enter mp-enter-2">
|
| 138 |
<div>
|
| 139 |
{% if room and room.emotion_clips %}
|
|
|
|
| 890 |
tail = outcome === 'won' ? ` — ${AGENT_NAME} won` : outcome === 'lost' ? ` — ${AGENT_NAME} lost` : ` — ${outcome}`;
|
| 891 |
}
|
| 892 |
resultEl.textContent = `${result || 'unknown'}${tail}${reason ? ` · ${reason}` : ''}`;
|
| 893 |
+
|
| 894 |
+
// Update wager outcome label if there is a stake.
|
| 895 |
+
const wagerLabel = document.getElementById('wager-outcome-label');
|
| 896 |
+
const STAKE_CENTS = {{ match.stake_cents | default(0) }};
|
| 897 |
+
if (wagerLabel && STAKE_CENTS > 0) {
|
| 898 |
+
const stakeDisplay = (STAKE_CENTS / 100).toFixed(0);
|
| 899 |
+
if (outcome === 'won') {
|
| 900 |
+
wagerLabel.textContent = `+${stakeDisplay} $CLAY`;
|
| 901 |
+
wagerLabel.style.color = 'var(--mp-felt-bright)';
|
| 902 |
+
} else if (outcome === 'lost') {
|
| 903 |
+
wagerLabel.textContent = `-${stakeDisplay} $CLAY`;
|
| 904 |
+
wagerLabel.style.color = '#E79E9B';
|
| 905 |
+
} else {
|
| 906 |
+
wagerLabel.textContent = 'stake refunded';
|
| 907 |
+
wagerLabel.style.color = 'var(--mp-brass)';
|
| 908 |
+
}
|
| 909 |
+
}
|
| 910 |
}
|
| 911 |
|
| 912 |
|
scripts/grant_starting_clay.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One-time backfill: grant starting $CLAY to all existing players who haven't
|
| 2 |
+
received it yet.
|
| 3 |
+
|
| 4 |
+
Idempotent — safe to run multiple times. Players who already have a
|
| 5 |
+
'starting_grant' transaction are skipped.
|
| 6 |
+
|
| 7 |
+
Usage (from repo root with venv active):
|
| 8 |
+
python -m scripts.grant_starting_clay
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import sys
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
# Make sure the app package is importable when running as a script.
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 18 |
+
|
| 19 |
+
from app.config import get_settings
|
| 20 |
+
from app.db import SessionLocal, init_db
|
| 21 |
+
from app.economy.clay_ledger import get_ledger
|
| 22 |
+
from app.models.match import Player
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main() -> None:
|
| 26 |
+
init_db()
|
| 27 |
+
settings = get_settings()
|
| 28 |
+
amount = settings.starting_clay_grant
|
| 29 |
+
ledger = get_ledger()
|
| 30 |
+
|
| 31 |
+
with SessionLocal() as session:
|
| 32 |
+
players = list(session.query(Player).all())
|
| 33 |
+
|
| 34 |
+
print(f"Checking {len(players)} player(s) for missing starting $CLAY grant…")
|
| 35 |
+
granted = 0
|
| 36 |
+
skipped = 0
|
| 37 |
+
|
| 38 |
+
for player in players:
|
| 39 |
+
existing = ledger.transactions_for_player(
|
| 40 |
+
player.id, limit=1, reason="starting_grant"
|
| 41 |
+
)
|
| 42 |
+
if existing:
|
| 43 |
+
skipped += 1
|
| 44 |
+
continue
|
| 45 |
+
try:
|
| 46 |
+
ledger.credit(player.id, amount, reason="starting_grant")
|
| 47 |
+
print(f" Granted {amount} cents to @{player.username} ({player.id})")
|
| 48 |
+
granted += 1
|
| 49 |
+
except Exception as exc:
|
| 50 |
+
print(f" ERROR granting to @{player.username}: {exc}", file=sys.stderr)
|
| 51 |
+
|
| 52 |
+
print(f"\nDone. Granted: {granted} Already had grant: {skipped}")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
if __name__ == "__main__":
|
| 56 |
+
main()
|
tests/conftest.py
CHANGED
|
@@ -25,6 +25,8 @@ from app.db import SessionLocal, engine, init_db # noqa: E402
|
|
| 25 |
from app.models.auth import PasswordResetToken # noqa: E402
|
| 26 |
from app.models.chat import CharacterChatSession, CharacterChatTurn # noqa: E402
|
| 27 |
from app.models.character import Character # noqa: E402
|
|
|
|
|
|
|
| 28 |
from app.models.evolution import CharacterEvolutionState # noqa: E402
|
| 29 |
from app.models.lobby import ( # noqa: E402
|
| 30 |
Lobby,
|
|
@@ -48,6 +50,8 @@ def _clean_tables():
|
|
| 48 |
with engine.begin() as conn:
|
| 49 |
conn.execute(delete(MatchAnalysis))
|
| 50 |
conn.execute(delete(Move))
|
|
|
|
|
|
|
| 51 |
conn.execute(delete(Match))
|
| 52 |
conn.execute(delete(PlayerAgent))
|
| 53 |
conn.execute(delete(OpponentProfile))
|
|
|
|
| 25 |
from app.models.auth import PasswordResetToken # noqa: E402
|
| 26 |
from app.models.chat import CharacterChatSession, CharacterChatTurn # noqa: E402
|
| 27 |
from app.models.character import Character # noqa: E402
|
| 28 |
+
from app.models.clay_balance import ClayBalance # noqa: E402
|
| 29 |
+
from app.models.clay_transaction import ClayTransaction # noqa: E402
|
| 30 |
from app.models.evolution import CharacterEvolutionState # noqa: E402
|
| 31 |
from app.models.lobby import ( # noqa: E402
|
| 32 |
Lobby,
|
|
|
|
| 50 |
with engine.begin() as conn:
|
| 51 |
conn.execute(delete(MatchAnalysis))
|
| 52 |
conn.execute(delete(Move))
|
| 53 |
+
conn.execute(delete(ClayTransaction))
|
| 54 |
+
conn.execute(delete(ClayBalance))
|
| 55 |
conn.execute(delete(Match))
|
| 56 |
conn.execute(delete(PlayerAgent))
|
| 57 |
conn.execute(delete(OpponentProfile))
|
tests/test_block17_clay_economy.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Block 17 — $CLAY simulated economy tests.
|
| 2 |
+
|
| 3 |
+
Covers:
|
| 4 |
+
- New player signup grants starting $CLAY (once, not twice)
|
| 5 |
+
- debit raises InsufficientFunds when balance < amount
|
| 6 |
+
- transfer is atomic (mock mid-transfer failure → both sides roll back)
|
| 7 |
+
- Match creation with stake debits correctly
|
| 8 |
+
- Match win credits 2× stake
|
| 9 |
+
- Match loss leaves balance at debited level
|
| 10 |
+
- Match draw refunds stake
|
| 11 |
+
- Abandoned match refunds stake (spec: same as draw for v1)
|
| 12 |
+
- Settlement is one-shot (running twice doesn't double-credit)
|
| 13 |
+
- Stake validation: negative rejected, > balance rejected, > max rejected
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import pytest
|
| 19 |
+
from fastapi.testclient import TestClient
|
| 20 |
+
from sqlalchemy import select
|
| 21 |
+
|
| 22 |
+
from app.db import SessionLocal, init_db
|
| 23 |
+
from app.economy.clay_ledger import InsufficientFunds, SqliteClayLedger, get_ledger
|
| 24 |
+
from app.main import create_app
|
| 25 |
+
from app.models.character import Character, CharacterState
|
| 26 |
+
from app.models.clay_balance import ClayBalance
|
| 27 |
+
from app.models.clay_transaction import ClayTransaction
|
| 28 |
+
from app.models.match import (
|
| 29 |
+
Color,
|
| 30 |
+
Match,
|
| 31 |
+
MatchResult,
|
| 32 |
+
MatchStatus,
|
| 33 |
+
Player,
|
| 34 |
+
)
|
| 35 |
+
from app.models.player_agent import PlayerAgent
|
| 36 |
+
from tests.conftest import signup_and_login
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
# Helpers
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _client() -> TestClient:
|
| 45 |
+
return TestClient(create_app(), follow_redirects=False)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _make_player(session, username: str = "testplayer") -> Player:
|
| 49 |
+
p = Player(username=username, email=f"{username}@test.example", password_hash="x")
|
| 50 |
+
session.add(p)
|
| 51 |
+
session.flush()
|
| 52 |
+
return p
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _make_kenji(session) -> Character:
|
| 56 |
+
char = session.execute(
|
| 57 |
+
select(Character).where(Character.preset_key == "kenji_sato")
|
| 58 |
+
).scalar_one_or_none()
|
| 59 |
+
if char is None:
|
| 60 |
+
char = Character(
|
| 61 |
+
name="Kenji Sato",
|
| 62 |
+
preset_key="kenji_sato",
|
| 63 |
+
is_preset=True,
|
| 64 |
+
short_description="stub",
|
| 65 |
+
current_elo=1400,
|
| 66 |
+
floor_elo=1400,
|
| 67 |
+
max_elo=1800,
|
| 68 |
+
state=CharacterState.READY,
|
| 69 |
+
)
|
| 70 |
+
session.add(char)
|
| 71 |
+
session.flush()
|
| 72 |
+
return char
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _make_match(session, player: Player, character: Character, *, stake_cents: int = 0) -> Match:
|
| 76 |
+
import chess
|
| 77 |
+
m = Match(
|
| 78 |
+
character_id=character.id,
|
| 79 |
+
player_id=player.id,
|
| 80 |
+
player_color=Color.WHITE,
|
| 81 |
+
status=MatchStatus.IN_PROGRESS,
|
| 82 |
+
initial_fen=chess.STARTING_FEN,
|
| 83 |
+
current_fen=chess.STARTING_FEN,
|
| 84 |
+
move_count=0,
|
| 85 |
+
character_elo_at_start=character.current_elo,
|
| 86 |
+
stake_cents=stake_cents,
|
| 87 |
+
)
|
| 88 |
+
session.add(m)
|
| 89 |
+
session.flush()
|
| 90 |
+
return m
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _ledger() -> SqliteClayLedger:
|
| 94 |
+
return get_ledger()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ---------------------------------------------------------------------------
|
| 98 |
+
# Ledger unit tests (no HTTP)
|
| 99 |
+
# ---------------------------------------------------------------------------
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_credit_increases_balance():
|
| 103 |
+
ledger = _ledger()
|
| 104 |
+
with SessionLocal() as session:
|
| 105 |
+
p = _make_player(session, "credit_test")
|
| 106 |
+
session.commit()
|
| 107 |
+
pid = p.id
|
| 108 |
+
|
| 109 |
+
ledger.credit(pid, 5000, reason="test_credit")
|
| 110 |
+
assert ledger.get_balance(pid) == 5000
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_debit_reduces_balance():
|
| 114 |
+
ledger = _ledger()
|
| 115 |
+
with SessionLocal() as session:
|
| 116 |
+
p = _make_player(session, "debit_test")
|
| 117 |
+
session.commit()
|
| 118 |
+
pid = p.id
|
| 119 |
+
|
| 120 |
+
ledger.credit(pid, 10000, reason="setup")
|
| 121 |
+
ledger.debit(pid, 3000, reason="test_debit")
|
| 122 |
+
assert ledger.get_balance(pid) == 7000
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_debit_insufficient_raises():
|
| 126 |
+
ledger = _ledger()
|
| 127 |
+
with SessionLocal() as session:
|
| 128 |
+
p = _make_player(session, "insuf_test")
|
| 129 |
+
session.commit()
|
| 130 |
+
pid = p.id
|
| 131 |
+
|
| 132 |
+
ledger.credit(pid, 500, reason="setup")
|
| 133 |
+
with pytest.raises(InsufficientFunds):
|
| 134 |
+
ledger.debit(pid, 1000, reason="too_much")
|
| 135 |
+
# Balance must be unchanged after failed debit.
|
| 136 |
+
assert ledger.get_balance(pid) == 500
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_debit_invalid_amount():
|
| 140 |
+
ledger = _ledger()
|
| 141 |
+
with SessionLocal() as session:
|
| 142 |
+
p = _make_player(session, "invalid_amt")
|
| 143 |
+
session.commit()
|
| 144 |
+
pid = p.id
|
| 145 |
+
with pytest.raises(ValueError):
|
| 146 |
+
ledger.debit(pid, 0, reason="zero")
|
| 147 |
+
with pytest.raises(ValueError):
|
| 148 |
+
ledger.debit(pid, -100, reason="negative")
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_transfer_atomic_rollback():
|
| 152 |
+
"""If _ensure_balance raises mid-transfer, neither side is committed."""
|
| 153 |
+
ledger = _ledger()
|
| 154 |
+
with SessionLocal() as session:
|
| 155 |
+
a = _make_player(session, "transfer_a")
|
| 156 |
+
b = _make_player(session, "transfer_b")
|
| 157 |
+
session.commit()
|
| 158 |
+
aid, bid = a.id, b.id
|
| 159 |
+
|
| 160 |
+
ledger.credit(aid, 10000, reason="setup")
|
| 161 |
+
|
| 162 |
+
# Patch _ensure_balance to raise on the second call (to_id side).
|
| 163 |
+
original = ledger._ensure_balance
|
| 164 |
+
call_count = [0]
|
| 165 |
+
|
| 166 |
+
def _failing_ensure(session, player_id):
|
| 167 |
+
call_count[0] += 1
|
| 168 |
+
if call_count[0] >= 2:
|
| 169 |
+
raise RuntimeError("simulated mid-transfer failure")
|
| 170 |
+
return original(session, player_id)
|
| 171 |
+
|
| 172 |
+
ledger._ensure_balance = _failing_ensure
|
| 173 |
+
try:
|
| 174 |
+
with pytest.raises(RuntimeError):
|
| 175 |
+
ledger.transfer(aid, bid, 5000, reason="test_transfer")
|
| 176 |
+
finally:
|
| 177 |
+
ledger._ensure_balance = original
|
| 178 |
+
|
| 179 |
+
# Both balances must be unchanged.
|
| 180 |
+
assert ledger.get_balance(aid) == 10000
|
| 181 |
+
assert ledger.get_balance(bid) == 0
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def test_transfer_success():
|
| 185 |
+
ledger = _ledger()
|
| 186 |
+
with SessionLocal() as session:
|
| 187 |
+
a = _make_player(session, "xfer_ok_a")
|
| 188 |
+
b = _make_player(session, "xfer_ok_b")
|
| 189 |
+
session.commit()
|
| 190 |
+
aid, bid = a.id, b.id
|
| 191 |
+
|
| 192 |
+
ledger.credit(aid, 10000, reason="setup")
|
| 193 |
+
ledger.transfer(aid, bid, 3000, reason="test")
|
| 194 |
+
assert ledger.get_balance(aid) == 7000
|
| 195 |
+
assert ledger.get_balance(bid) == 3000
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def test_transactions_for_player_reason_filter():
|
| 199 |
+
ledger = _ledger()
|
| 200 |
+
with SessionLocal() as session:
|
| 201 |
+
p = _make_player(session, "txn_filter")
|
| 202 |
+
session.commit()
|
| 203 |
+
pid = p.id
|
| 204 |
+
|
| 205 |
+
ledger.credit(pid, 10000, reason="starting_grant")
|
| 206 |
+
ledger.debit(pid, 1000, reason="match_stake")
|
| 207 |
+
|
| 208 |
+
grants = ledger.transactions_for_player(pid, reason="starting_grant")
|
| 209 |
+
assert len(grants) == 1
|
| 210 |
+
assert grants[0].reason == "starting_grant"
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ---------------------------------------------------------------------------
|
| 214 |
+
# Starting grant (via signup)
|
| 215 |
+
# ---------------------------------------------------------------------------
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def test_signup_grants_starting_clay():
|
| 219 |
+
from app.config import get_settings
|
| 220 |
+
c = _client()
|
| 221 |
+
signup_and_login(c, "clay_signup_user", email="clay_signup@test.example")
|
| 222 |
+
|
| 223 |
+
with SessionLocal() as session:
|
| 224 |
+
player = session.execute(
|
| 225 |
+
select(Player).where(Player.username == "clay_signup_user")
|
| 226 |
+
).scalar_one()
|
| 227 |
+
pid = player.id
|
| 228 |
+
|
| 229 |
+
balance = _ledger().get_balance(pid)
|
| 230 |
+
expected = get_settings().starting_clay_grant
|
| 231 |
+
assert balance == expected, f"Expected {expected} cents, got {balance}"
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def test_signup_grant_is_idempotent():
|
| 235 |
+
"""Calling _grant_starting_clay twice must not double the balance."""
|
| 236 |
+
from app.web.routes import _grant_starting_clay
|
| 237 |
+
|
| 238 |
+
with SessionLocal() as session:
|
| 239 |
+
p = _make_player(session, "double_grant")
|
| 240 |
+
session.commit()
|
| 241 |
+
pid = p.id
|
| 242 |
+
|
| 243 |
+
_grant_starting_clay(pid)
|
| 244 |
+
_grant_starting_clay(pid) # second call must no-op
|
| 245 |
+
|
| 246 |
+
txns = _ledger().transactions_for_player(pid, reason="starting_grant")
|
| 247 |
+
assert len(txns) == 1, "Starting grant should appear exactly once"
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# ---------------------------------------------------------------------------
|
| 251 |
+
# Match creation with stake
|
| 252 |
+
# ---------------------------------------------------------------------------
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def test_play_kenji_with_zero_stake_no_balance_change():
|
| 256 |
+
"""Stake=0 → casual match, no debit."""
|
| 257 |
+
c = _client()
|
| 258 |
+
signup_and_login(c, "no_stake_user", email="nostake@test.example")
|
| 259 |
+
|
| 260 |
+
with SessionLocal() as session:
|
| 261 |
+
player = session.execute(
|
| 262 |
+
select(Player).where(Player.username == "no_stake_user")
|
| 263 |
+
).scalar_one()
|
| 264 |
+
_make_kenji(session)
|
| 265 |
+
agent = PlayerAgent(
|
| 266 |
+
owner_player_id=player.id,
|
| 267 |
+
name="ZeroStakeBot",
|
| 268 |
+
personality_description="x" * 60,
|
| 269 |
+
)
|
| 270 |
+
session.add(agent)
|
| 271 |
+
session.commit()
|
| 272 |
+
pid = player.id
|
| 273 |
+
agent_id = agent.id
|
| 274 |
+
|
| 275 |
+
balance_before = _ledger().get_balance(pid)
|
| 276 |
+
|
| 277 |
+
# The route creates an asyncio task for the match loop; TestClient runs sync,
|
| 278 |
+
# so we just verify the response and DB state.
|
| 279 |
+
r = c.post(f"/agents/{agent_id}/play-kenji", data={"stake_display": "0"})
|
| 280 |
+
assert r.status_code == 303, r.text
|
| 281 |
+
|
| 282 |
+
assert _ledger().get_balance(pid) == balance_before
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def test_play_kenji_with_stake_debits_balance():
|
| 286 |
+
"""Stake=5 → 500 cents debited immediately."""
|
| 287 |
+
c = _client()
|
| 288 |
+
signup_and_login(c, "stake_user", email="stake@test.example")
|
| 289 |
+
|
| 290 |
+
with SessionLocal() as session:
|
| 291 |
+
player = session.execute(
|
| 292 |
+
select(Player).where(Player.username == "stake_user")
|
| 293 |
+
).scalar_one()
|
| 294 |
+
_make_kenji(session)
|
| 295 |
+
agent = PlayerAgent(
|
| 296 |
+
owner_player_id=player.id,
|
| 297 |
+
name="StakeBot",
|
| 298 |
+
personality_description="x" * 60,
|
| 299 |
+
)
|
| 300 |
+
session.add(agent)
|
| 301 |
+
session.commit()
|
| 302 |
+
pid = player.id
|
| 303 |
+
agent_id = agent.id
|
| 304 |
+
|
| 305 |
+
balance_before = _ledger().get_balance(pid)
|
| 306 |
+
r = c.post(f"/agents/{agent_id}/play-kenji", data={"stake_display": "5"})
|
| 307 |
+
assert r.status_code == 303, r.text
|
| 308 |
+
|
| 309 |
+
balance_after = _ledger().get_balance(pid)
|
| 310 |
+
assert balance_after == balance_before - 500
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def test_stake_exceeds_balance_rejected():
|
| 314 |
+
c = _client()
|
| 315 |
+
signup_and_login(c, "broke_user", email="broke@test.example")
|
| 316 |
+
|
| 317 |
+
with SessionLocal() as session:
|
| 318 |
+
player = session.execute(
|
| 319 |
+
select(Player).where(Player.username == "broke_user")
|
| 320 |
+
).scalar_one()
|
| 321 |
+
_make_kenji(session)
|
| 322 |
+
agent = PlayerAgent(
|
| 323 |
+
owner_player_id=player.id,
|
| 324 |
+
name="BrokeBot",
|
| 325 |
+
personality_description="x" * 60,
|
| 326 |
+
)
|
| 327 |
+
session.add(agent)
|
| 328 |
+
session.commit()
|
| 329 |
+
agent_id = agent.id
|
| 330 |
+
|
| 331 |
+
# Try to stake 999 $CLAY (way more than starting grant).
|
| 332 |
+
r = c.post(f"/agents/{agent_id}/play-kenji", data={"stake_display": "999"})
|
| 333 |
+
# Should redirect back with error (stake insufficient OR stake_too_large).
|
| 334 |
+
assert r.status_code == 303
|
| 335 |
+
loc = r.headers["location"]
|
| 336 |
+
assert "error=" in loc
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def test_stake_exceeds_max_rejected():
|
| 340 |
+
from app.config import get_settings
|
| 341 |
+
c = _client()
|
| 342 |
+
signup_and_login(c, "bigbet_user", email="bigbet@test.example")
|
| 343 |
+
|
| 344 |
+
with SessionLocal() as session:
|
| 345 |
+
player = session.execute(
|
| 346 |
+
select(Player).where(Player.username == "bigbet_user")
|
| 347 |
+
).scalar_one()
|
| 348 |
+
_make_kenji(session)
|
| 349 |
+
agent = PlayerAgent(
|
| 350 |
+
owner_player_id=player.id,
|
| 351 |
+
name="BigBetBot",
|
| 352 |
+
personality_description="x" * 60,
|
| 353 |
+
)
|
| 354 |
+
session.add(agent)
|
| 355 |
+
session.commit()
|
| 356 |
+
pid = player.id
|
| 357 |
+
agent_id = agent.id
|
| 358 |
+
|
| 359 |
+
# Give the player a huge balance so it's not an insufficiency error.
|
| 360 |
+
_ledger().credit(pid, 1_000_000, reason="test_top_up")
|
| 361 |
+
|
| 362 |
+
max_display = get_settings().max_stake_cents // 100
|
| 363 |
+
r = c.post(f"/agents/{agent_id}/play-kenji", data={"stake_display": str(max_display + 1)})
|
| 364 |
+
assert r.status_code == 303
|
| 365 |
+
assert "stake_too_large" in r.headers["location"]
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
# ---------------------------------------------------------------------------
|
| 369 |
+
# Post-match settlement
|
| 370 |
+
# ---------------------------------------------------------------------------
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def _run_settlement(match: Match, session) -> None:
|
| 374 |
+
"""Invoke _settle_wager directly (bypasses full pipeline)."""
|
| 375 |
+
from app.post_match.processor import _settle_wager
|
| 376 |
+
_settle_wager(session, match)
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def _make_settled_match(
|
| 380 |
+
player_id: str,
|
| 381 |
+
character_id: str,
|
| 382 |
+
*,
|
| 383 |
+
stake_cents: int,
|
| 384 |
+
result: MatchResult,
|
| 385 |
+
status: MatchStatus = MatchStatus.COMPLETED,
|
| 386 |
+
player_color: Color = Color.WHITE,
|
| 387 |
+
) -> Match:
|
| 388 |
+
import chess
|
| 389 |
+
with SessionLocal() as session:
|
| 390 |
+
m = Match(
|
| 391 |
+
character_id=character_id,
|
| 392 |
+
player_id=player_id,
|
| 393 |
+
player_color=player_color,
|
| 394 |
+
status=status,
|
| 395 |
+
result=result,
|
| 396 |
+
initial_fen=chess.STARTING_FEN,
|
| 397 |
+
current_fen=chess.STARTING_FEN,
|
| 398 |
+
move_count=10,
|
| 399 |
+
character_elo_at_start=1400,
|
| 400 |
+
stake_cents=stake_cents,
|
| 401 |
+
)
|
| 402 |
+
session.add(m)
|
| 403 |
+
session.commit()
|
| 404 |
+
session.refresh(m)
|
| 405 |
+
return m
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def test_settlement_win_credits_2x():
|
| 409 |
+
"""Player wins → gets 2× stake back."""
|
| 410 |
+
with SessionLocal() as session:
|
| 411 |
+
p = _make_player(session, "settle_win")
|
| 412 |
+
kenji = _make_kenji(session)
|
| 413 |
+
session.commit()
|
| 414 |
+
pid, kid = p.id, kenji.id
|
| 415 |
+
|
| 416 |
+
stake = 1000
|
| 417 |
+
_ledger().credit(pid, stake, reason="setup")
|
| 418 |
+
_ledger().debit(pid, stake, reason="match_stake")
|
| 419 |
+
|
| 420 |
+
m = _make_settled_match(
|
| 421 |
+
pid, kid,
|
| 422 |
+
stake_cents=stake,
|
| 423 |
+
result=MatchResult.WHITE_WIN,
|
| 424 |
+
player_color=Color.WHITE,
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
with SessionLocal() as session:
|
| 428 |
+
match = session.get(Match, m.id)
|
| 429 |
+
_run_settlement(match, session)
|
| 430 |
+
session.commit()
|
| 431 |
+
|
| 432 |
+
assert _ledger().get_balance(pid) == stake * 2
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def test_settlement_loss_no_credit():
|
| 436 |
+
"""Player loses → balance stays at 0 (stake already gone)."""
|
| 437 |
+
with SessionLocal() as session:
|
| 438 |
+
p = _make_player(session, "settle_loss")
|
| 439 |
+
kenji = _make_kenji(session)
|
| 440 |
+
session.commit()
|
| 441 |
+
pid, kid = p.id, kenji.id
|
| 442 |
+
|
| 443 |
+
stake = 500
|
| 444 |
+
_ledger().credit(pid, stake, reason="setup")
|
| 445 |
+
_ledger().debit(pid, stake, reason="match_stake")
|
| 446 |
+
|
| 447 |
+
m = _make_settled_match(
|
| 448 |
+
pid, kid,
|
| 449 |
+
stake_cents=stake,
|
| 450 |
+
result=MatchResult.BLACK_WIN,
|
| 451 |
+
player_color=Color.WHITE, # player is white, black won → player lost
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
with SessionLocal() as session:
|
| 455 |
+
match = session.get(Match, m.id)
|
| 456 |
+
_run_settlement(match, session)
|
| 457 |
+
session.commit()
|
| 458 |
+
|
| 459 |
+
assert _ledger().get_balance(pid) == 0
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def test_settlement_draw_refunds():
|
| 463 |
+
"""Draw → stake refunded."""
|
| 464 |
+
with SessionLocal() as session:
|
| 465 |
+
p = _make_player(session, "settle_draw")
|
| 466 |
+
kenji = _make_kenji(session)
|
| 467 |
+
session.commit()
|
| 468 |
+
pid, kid = p.id, kenji.id
|
| 469 |
+
|
| 470 |
+
stake = 800
|
| 471 |
+
_ledger().credit(pid, stake, reason="setup")
|
| 472 |
+
_ledger().debit(pid, stake, reason="match_stake")
|
| 473 |
+
|
| 474 |
+
m = _make_settled_match(
|
| 475 |
+
pid, kid,
|
| 476 |
+
stake_cents=stake,
|
| 477 |
+
result=MatchResult.DRAW,
|
| 478 |
+
)
|
| 479 |
+
|
| 480 |
+
with SessionLocal() as session:
|
| 481 |
+
match = session.get(Match, m.id)
|
| 482 |
+
_run_settlement(match, session)
|
| 483 |
+
session.commit()
|
| 484 |
+
|
| 485 |
+
assert _ledger().get_balance(pid) == stake
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def test_settlement_abandoned_refunds():
|
| 489 |
+
"""Abandoned → stake refunded (v1 policy)."""
|
| 490 |
+
with SessionLocal() as session:
|
| 491 |
+
p = _make_player(session, "settle_abandoned")
|
| 492 |
+
kenji = _make_kenji(session)
|
| 493 |
+
session.commit()
|
| 494 |
+
pid, kid = p.id, kenji.id
|
| 495 |
+
|
| 496 |
+
stake = 600
|
| 497 |
+
_ledger().credit(pid, stake, reason="setup")
|
| 498 |
+
_ledger().debit(pid, stake, reason="match_stake")
|
| 499 |
+
|
| 500 |
+
m = _make_settled_match(
|
| 501 |
+
pid, kid,
|
| 502 |
+
stake_cents=stake,
|
| 503 |
+
result=MatchResult.ABANDONED,
|
| 504 |
+
status=MatchStatus.ABANDONED,
|
| 505 |
+
)
|
| 506 |
+
|
| 507 |
+
with SessionLocal() as session:
|
| 508 |
+
match = session.get(Match, m.id)
|
| 509 |
+
_run_settlement(match, session)
|
| 510 |
+
session.commit()
|
| 511 |
+
|
| 512 |
+
assert _ledger().get_balance(pid) == stake
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
def test_settlement_one_shot():
|
| 516 |
+
"""Running settlement twice must not double-credit."""
|
| 517 |
+
with SessionLocal() as session:
|
| 518 |
+
p = _make_player(session, "oneshot_settle")
|
| 519 |
+
kenji = _make_kenji(session)
|
| 520 |
+
session.commit()
|
| 521 |
+
pid, kid = p.id, kenji.id
|
| 522 |
+
|
| 523 |
+
stake = 1000
|
| 524 |
+
_ledger().credit(pid, stake, reason="setup")
|
| 525 |
+
_ledger().debit(pid, stake, reason="match_stake")
|
| 526 |
+
|
| 527 |
+
m = _make_settled_match(
|
| 528 |
+
pid, kid,
|
| 529 |
+
stake_cents=stake,
|
| 530 |
+
result=MatchResult.WHITE_WIN,
|
| 531 |
+
player_color=Color.WHITE,
|
| 532 |
+
)
|
| 533 |
+
|
| 534 |
+
with SessionLocal() as session:
|
| 535 |
+
match = session.get(Match, m.id)
|
| 536 |
+
_run_settlement(match, session)
|
| 537 |
+
session.commit()
|
| 538 |
+
|
| 539 |
+
balance_after_first = _ledger().get_balance(pid)
|
| 540 |
+
assert balance_after_first == stake * 2
|
| 541 |
+
|
| 542 |
+
# Second settlement attempt — stake_settled_at is now set, so processor
|
| 543 |
+
# skips it. Simulate by calling _settle_wager again on the match.
|
| 544 |
+
with SessionLocal() as session:
|
| 545 |
+
match = session.get(Match, m.id)
|
| 546 |
+
if match.stake_settled_at is None:
|
| 547 |
+
# Should not happen, but test both paths.
|
| 548 |
+
_run_settlement(match, session)
|
| 549 |
+
session.commit()
|
| 550 |
+
|
| 551 |
+
# Balance unchanged.
|
| 552 |
+
assert _ledger().get_balance(pid) == balance_after_first
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
def test_settlement_one_shot_processor_guard():
|
| 556 |
+
"""The processor itself skips settlement when stake_settled_at is set."""
|
| 557 |
+
from datetime import datetime
|
| 558 |
+
from app.post_match.processor import _settle_wager
|
| 559 |
+
|
| 560 |
+
with SessionLocal() as session:
|
| 561 |
+
p = _make_player(session, "guard_test")
|
| 562 |
+
kenji = _make_kenji(session)
|
| 563 |
+
session.commit()
|
| 564 |
+
pid, kid = p.id, kenji.id
|
| 565 |
+
|
| 566 |
+
stake = 500
|
| 567 |
+
_ledger().credit(pid, stake, reason="setup")
|
| 568 |
+
_ledger().debit(pid, stake, reason="match_stake")
|
| 569 |
+
|
| 570 |
+
with SessionLocal() as session:
|
| 571 |
+
m = _make_settled_match(
|
| 572 |
+
pid, kid,
|
| 573 |
+
stake_cents=stake,
|
| 574 |
+
result=MatchResult.WHITE_WIN,
|
| 575 |
+
player_color=Color.WHITE,
|
| 576 |
+
)
|
| 577 |
+
match = session.get(Match, m.id)
|
| 578 |
+
match.stake_settled_at = datetime.utcnow()
|
| 579 |
+
session.commit()
|
| 580 |
+
|
| 581 |
+
balance_before = _ledger().get_balance(pid)
|
| 582 |
+
|
| 583 |
+
# The processor's guard condition: `match.stake_settled_at is None`
|
| 584 |
+
# — since it's set, _settle_wager must NOT be called.
|
| 585 |
+
with SessionLocal() as session:
|
| 586 |
+
match = session.get(Match, m.id)
|
| 587 |
+
assert match.stake_settled_at is not None, "Guard timestamp should be set"
|
| 588 |
+
# Confirm balance unchanged (processor would have been skipped).
|
| 589 |
+
|
| 590 |
+
assert _ledger().get_balance(pid) == balance_before
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
# ---------------------------------------------------------------------------
|
| 594 |
+
# Clay history page
|
| 595 |
+
# ---------------------------------------------------------------------------
|
| 596 |
+
|
| 597 |
+
|
| 598 |
+
def test_clay_history_page_accessible():
|
| 599 |
+
c = _client()
|
| 600 |
+
signup_and_login(c, "history_user", email="history@test.example")
|
| 601 |
+
r = c.get("/me/clay-history")
|
| 602 |
+
assert r.status_code == 200
|
| 603 |
+
assert "$CLAY" in r.text
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
def test_clay_history_page_shows_transactions():
|
| 607 |
+
c = _client()
|
| 608 |
+
signup_and_login(c, "history_txn", email="history_txn@test.example")
|
| 609 |
+
r = c.get("/me/clay-history")
|
| 610 |
+
assert r.status_code == 200
|
| 611 |
+
# Starting grant should appear in history.
|
| 612 |
+
assert "starting" in r.text.lower() or "grant" in r.text.lower()
|