diff --git a/.env.example b/.env.example index 36e674b..eb5d3d6 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,4 @@ JWT_SECRET= ADMIN_TOKEN= ROUND_DURATION_SECONDS=600 +ROUND_COOLDOWN_SECONDS=30 diff --git a/CLAUDE.md b/CLAUDE.md index 6508b9e..8118d53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,7 @@ Known risk: `docker-compose.yml` sets `restart: unless-stopped` on `app`, so a c - **Secrets**: master xprv encrypted at rest with a symmetric scheme (AES-GCM/Fernet); the encryption key itself lives in an env var, never in the DB or in git. - **Operational config** (fee/commission address, RBF fee-bump wallet, etc.): stored in a DB config table, not env vars — must be editable without a redeploy. - **Round duration**: configurable via env var, default 10 minutes (not hardcoded). +- **Round cooldown**: `ROUND_COOLDOWN_SECONDS` (default 30s) — gap after a round closes before the next one opens, so players have time to see the outcome. Not in the original flowchart; added afterwards as an explicit design decision. ## PLM network parameters diff --git a/app/bets/service.py b/app/bets/service.py index aeb22f2..dc3e906 100644 --- a/app/bets/service.py +++ b/app/bets/service.py @@ -21,6 +21,8 @@ class BetError(Exception): async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant: round_ = await open_new_round_if_needed(session) + if round_ is None: + raise BetError("no round open right now, please try again shortly") if round_.status != "open": raise BetError("the current round is closing, please try again shortly") diff --git a/app/config.py b/app/config.py index f1bbf52..aef5b37 100644 --- a/app/config.py +++ b/app/config.py @@ -18,6 +18,7 @@ class Settings(BaseSettings): admin_token: str = "" round_duration_seconds: int = 600 + round_cooldown_seconds: int = 30 bet_amount_sats: int = 10 * 100_000_000 min_amount_sats: int = 1 * 100_000_000 diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index 34db08c..a06617e 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -50,6 +50,8 @@ class RoundScheduler: async with self._session_factory() as session: round_ = await open_new_round_if_needed(session) await session.commit() + if round_ is None: + return # still in the cooldown window after the last round closed round_id, status, opened_at = round_.id, round_.status, round_.opened_at if status != "open": diff --git a/app/rounds/service.py b/app/rounds/service.py index e7c219c..ca428d7 100644 --- a/app/rounds/service.py +++ b/app/rounds/service.py @@ -1,6 +1,9 @@ +from datetime import datetime, timedelta, timezone + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.db.models import Round _ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out") @@ -13,14 +16,23 @@ async def get_active_round(session: AsyncSession) -> Round | None: return await session.scalar(select(Round).where(Round.status.in_(_ACTIVE_STATUSES)).order_by(Round.id.desc())) -async def open_new_round_if_needed(session: AsyncSession) -> Round: - """Returns the active round if one exists (whatever its status), otherwise - opens a fresh one. Callers that need to attach a bet must additionally check - the returned round's status == "open" — a round in closing/drawing/paying_out - isn't accepting new bets, but a new round can't open until it's done.""" +async def open_new_round_if_needed(session: AsyncSession) -> Round | None: + """Returns the active round if one exists (whatever its status). Otherwise + opens a fresh one, unless the last closed round's cooldown (ROUND_COOLDOWN_SECONDS) + hasn't elapsed yet — in which case returns None. Callers that need to attach a + bet must additionally check the returned round's status == "open" — a round in + closing/drawing/paying_out isn't accepting new bets, but a new round can't open + until it's done.""" active = await get_active_round(session) if active is not None: return active + + last_closed = await session.scalar(select(Round).where(Round.status == "closed").order_by(Round.id.desc())) + if last_closed is not None and last_closed.closed_at is not None: + closed_at = last_closed.closed_at.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) < closed_at + timedelta(seconds=settings.round_cooldown_seconds): + return None + round_ = Round(status="open") session.add(round_) await session.flush() diff --git a/docs/guida-admin.md b/docs/guida-admin.md index f3ab6ff..6f586d3 100644 --- a/docs/guida-admin.md +++ b/docs/guida-admin.md @@ -25,8 +25,9 @@ usa i bottoni: | **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee). Obbligatorio: i payout **non partono** se questo campo è vuoto. | | **Bet amount (PLM)** | Il costo fisso d'ingresso per round, mostrato/impostato in PLM (internamente il backend lavora in sats: 1 PLM = 100.000.000 sats). | -`ROUND_DURATION_SECONDS` (durata del round) **non** è qui: è una variabile -d'ambiente in `.env`, non modificabile a runtime — per cambiarla serve +`ROUND_DURATION_SECONDS` (durata del round) e `ROUND_COOLDOWN_SECONDS` (pausa +tra un round e il successivo, default 30s) **non** sono qui: sono variabili +d'ambiente in `.env`, non modificabili a runtime — per cambiarle serve riavviare il server con il nuovo valore. ## Alternative all'interfaccia grafica diff --git a/tests/unit/test_rounds_service.py b/tests/unit/test_rounds_service.py index 0ef8937..bb2ed8b 100644 --- a/tests/unit/test_rounds_service.py +++ b/tests/unit/test_rounds_service.py @@ -1,6 +1,9 @@ +from datetime import datetime, timedelta, timezone + import pytest from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from app.config import settings from app.db.base import Base from app.db.models import Round from app.rounds.service import get_active_round, open_new_round_if_needed @@ -56,3 +59,24 @@ async def test_opens_new_round_after_previous_is_closed(session_factory): async with session_factory() as session: round_ = await open_new_round_if_needed(session) assert round_.status == "open" + + +async def test_withholds_new_round_during_cooldown(session_factory): + async with session_factory() as session: + session.add(Round(status="closed", closed_at=datetime.now(timezone.utc))) + await session.commit() + + async with session_factory() as session: + round_ = await open_new_round_if_needed(session) + assert round_ is None + + +async def test_opens_new_round_once_cooldown_elapses(session_factory): + stale_close = datetime.now(timezone.utc) - timedelta(seconds=settings.round_cooldown_seconds + 1) + async with session_factory() as session: + session.add(Round(status="closed", closed_at=stale_close)) + await session.commit() + + async with session_factory() as session: + round_ = await open_new_round_if_needed(session) + assert round_.status == "open"