Add a cooldown between rounds (ROUND_COOLDOWN_SECONDS)

open_new_round_if_needed now withholds opening the next round until
ROUND_COOLDOWN_SECONDS (default 30) have passed since the previous
round's closed_at, returning None in that window instead of a Round.
Without this, the next round opened within one scheduler tick (~5s) of
the previous payout confirming — not enough time for a player to
notice the round they were in actually resolved.

Callers updated: the scheduler treats None as "nothing to do this
tick", and place_bet raises a "try again shortly" BetError instead of
crashing on a None round.

Not in the original flowchart — a deliberate UX addition on top of it,
documented as such in CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 11:29:59 +02:00
co-authored by Claude Sonnet 5
parent abb3418669
commit f21ecbd4ee
8 changed files with 51 additions and 7 deletions
+1
View File
@@ -18,3 +18,4 @@ JWT_SECRET=
ADMIN_TOKEN= ADMIN_TOKEN=
ROUND_DURATION_SECONDS=600 ROUND_DURATION_SECONDS=600
ROUND_COOLDOWN_SECONDS=30
+1
View File
@@ -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. - **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. - **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 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 ## PLM network parameters
+2
View File
@@ -21,6 +21,8 @@ class BetError(Exception):
async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant: async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant:
round_ = await open_new_round_if_needed(session) 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": if round_.status != "open":
raise BetError("the current round is closing, please try again shortly") raise BetError("the current round is closing, please try again shortly")
+1
View File
@@ -18,6 +18,7 @@ class Settings(BaseSettings):
admin_token: str = "" admin_token: str = ""
round_duration_seconds: int = 600 round_duration_seconds: int = 600
round_cooldown_seconds: int = 30
bet_amount_sats: int = 10 * 100_000_000 bet_amount_sats: int = 10 * 100_000_000
min_amount_sats: int = 1 * 100_000_000 min_amount_sats: int = 1 * 100_000_000
+2
View File
@@ -50,6 +50,8 @@ class RoundScheduler:
async with self._session_factory() as session: async with self._session_factory() as session:
round_ = await open_new_round_if_needed(session) round_ = await open_new_round_if_needed(session)
await session.commit() 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 round_id, status, opened_at = round_.id, round_.status, round_.opened_at
if status != "open": if status != "open":
+17 -5
View File
@@ -1,6 +1,9 @@
from datetime import datetime, timedelta, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db.models import Round from app.db.models import Round
_ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out") _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())) 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: async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
"""Returns the active round if one exists (whatever its status), otherwise """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 opens a fresh one, unless the last closed round's cooldown (ROUND_COOLDOWN_SECONDS)
the returned round's status == "open" — a round in closing/drawing/paying_out hasn't elapsed yet — in which case returns None. Callers that need to attach a
isn't accepting new bets, but a new round can't open until it's done.""" 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) active = await get_active_round(session)
if active is not None: if active is not None:
return active 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") round_ = Round(status="open")
session.add(round_) session.add(round_)
await session.flush() await session.flush()
+3 -2
View File
@@ -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. | | **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). | | **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 `ROUND_DURATION_SECONDS` (durata del round) e `ROUND_COOLDOWN_SECONDS` (pausa
d'ambiente in `.env`, non modificabile a runtime — per cambiarla serve 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. riavviare il server con il nuovo valore.
## Alternative all'interfaccia grafica ## Alternative all'interfaccia grafica
+24
View File
@@ -1,6 +1,9 @@
from datetime import datetime, timedelta, timezone
import pytest import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings
from app.db.base import Base from app.db.base import Base
from app.db.models import Round from app.db.models import Round
from app.rounds.service import get_active_round, open_new_round_if_needed 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: async with session_factory() as session:
round_ = await open_new_round_if_needed(session) round_ = await open_new_round_if_needed(session)
assert round_.status == "open" 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"