Status: The shared credits contract, Mongo implementation, casino wallet cutover path, and migration tooling are implemented. Casino, Anonymous Whisperer, Truth or Dare, Hot Seat, and Dilemma are statically wired consumers. Truth or Dare’s source gameplay gate is enabled after operator-reported qualification; room-scoped party games still require their own enabled configuration and policy checks.
Archived: 2026-10-16 (UTC). See the live economy guide for current operations, retention, and migration guidance.
Promotes the casino’s persistent chip wallet into one audited, feature-neutral economy that the casino and independently implemented party-game plug-ins can use without importing one another. The shared economy is a prerequisite for games that award, reserve, spend, or forfeit points.
The implementation has one shared balance per Bondage Club member in the already selected runtime database. Its indivisible unit is credits in the API and storage contract. During a compatibility period the casino may display “chips,” but chips and credits must resolve to the same account and balance; there must not be one casino wallet plus a second party-game points wallet. Credits have no real-money value.
Game sessions and rules remain owned by each game. Account balances, economic reservations, grants, and the economic ledger are owned by the shared economy. Each consumer receives a narrow economy port from the composition root; no feature imports another feature’s service or repository.
Before this cutover, the casino had CasinoWalletService, a typed/Zod-validated
MongoCasinoRepository, a transactional ledger, idempotent reservations and
settlements, an entry grant, and per-game session recovery. Casino gameplay is
independently controlled by CASINO_GAMEPLAY_ENABLED; the current gate value
and operator-reported qualification are recorded in the casino guide and runtime
architecture.
The cutover moves the wallet and economic ledger to
prototypeEconomyAccounts and prototypeEconomyLedger; casino session records
remain casino-owned. The legacy prototypeCasinoWallets and
prototypeCasinoLedger remain only for the approved rollback window. Consumers
do not depend on casino services or repositories. The single-document
prototypeGameConfig is global, scalar-valued, and not a typed per-room game
configuration system; it is not a substitute for the room-scoped settings
proposed by the games.
core/economy/ defines transport- and database-neutral value types and an
EconomyPort. It contains no MongoDB imports or balance implementation.features/economy/ implements economy use cases and its feature-owned
repository. It owns validation, safe-integer limits, idempotency rules, and
the transaction boundary.start.ts constructs and initializes one economy service and injects the
same port into casino and game plug-ins. It remains the only composition
root.The economy service is a shared application capability, not a dependency
between plug-ins. If it has no user-facing commands or subscriptions, it does
not need a separate BotPlugin; the composition root initializes its
repository before plug-in startup and closes no resource other than the shared
Mongo client.
| Collection | Proposed contents | Ownership and lifecycle |
|---|---|---|
prototypeEconomyAccounts |
One account per BC memberNumber; schemaVersion, availableCredits, reservedCredits, optional lastEntryGrantAt, and timestamps. |
Economy repository; no TTL. Unique _id; non-negative safe integers. |
prototypeEconomyLedger |
Immutable entries with stable _id idempotency key, member number, operation/session IDs, source game, typed kind/reason code, available/reserved deltas, and dates. No chat, answer, secret, or prompt text. |
Economy repository; finalized entries and idempotency records have 60-day TTL retention. Unsettled reservations remain recoverable and are not TTL-deleted. The cutover marker is durable. |
| Feature session collections | Game-specific participants, phase, bounded outcomes, prompt IDs/revisions, config revision, deadlines, and settlement state. | Each game repository; no account balances or unbounded economic history. |
Account identity is global to the configured BC runtime database, matching the
current member-number wallet model. It is shared across rooms in that one
environment, but never across production and test, and never across the
legacy source database. A future non-BC channel would require a separately
approved identity mapping; it must not reuse a numeric member number as if it
were globally unique across services.
The proposed port should expose use cases rather than arbitrary balance mutation:
| Operation | Required behavior |
|---|---|
| Read balance | Return available and reserved credits; do not create a non-zero balance. |
| Create account | Initialize a missing account to zero. |
| Grant once | Require a bounded positive amount, approved source/reason, and stable operation ID. Duplicate IDs return the original result without crediting again. |
| Reserve | Atomically move available credits to reserved credits for a game/session. Reject invalid amounts, duplicate conflicting IDs, and insufficient funds. |
| Adjust reservation | Truth/Dare-only bid adjustment changes one active reservation atomically by the difference; target zero refunds and finalizes it. |
| Settle | Atomically consume or return a reservation and apply an approved reward; stable settlement ID makes retry safe. |
| Forfeit available | Set available credits to zero for the Hot Seat rule without touching other sessions’ reserved credits; emit one ledger entry. |
| Refund | Release a reservation exactly once after a void/cancel result. |
Every amount is a safe integer within a hard code-level ceiling. Per-room configuration may lower limits and tune approved rewards, but may not disable validation, select arbitrary ledger reasons, set balances directly, or create unbounded liabilities. The economy receives immutable operation IDs derived from persisted session IDs and event types. It never accepts player chat text as an idempotency key or ledger metadata.
Balance and ledger writes stay transactional. Game outcome/session state and
economy settlement may be separate durable steps: first persist the game
outcome and a settlement_pending marker, then call the idempotent economy
operation, then mark the session settled. A restart repeats only the same
operation ID; it cannot credit twice. If the economy outcome is ambiguous, the
session remains pending and reports an explicit failure for recovery rather
than claiming success.
No player-to-player transfers, real-money purchases, arbitrary operator grants, or external treasury are included. Any change to those capabilities requires its own authorization, abuse, audit, and release review.
--apply migration for the exact
allowlisted wallet and ledger collections. It must print counts and
aggregate credit totals, not account documents or player content; use
stable migration IDs; reject a target outside the fixed runtime database
mapping; and never read or write the legacy source database.The implemented migration command is npm run migrate:casino-economy. It is
dry-run by default, validates both source and destination before applying,
prints aggregate balances and counts only, and uses stable migration IDs.
Startup fails closed until its durable cutover marker exists. Apply retains the
legacy wallet and ledger data and extends expiring legacy ledger entries to
preserve the approved rollback period. During the 60-day rollback window,
npm run migrate:casino-economy -- --rollback previews restoring shared
balances to the legacy casino wallet; --rollback --apply performs that
restore. Rollback is rejected after post-cutover economic activity that cannot
be represented by the legacy casino wallet.
The migration must preserve the existing target/database isolation guard. It must not widen the Veratown config-copy script’s allowlist.
flowchart LR
Root["start.ts<br/>composition root"] --> Economy["EconomyService"]
Root --> Casino["CasinoFeaturePlugin"]
Root --> HotSeat["HotSeatPlugin"]
Root --> Dilemma["DilemmaPlugin"]
Root --> Dare["TruthDarePlugin"]
Root --> Whisperer["AnonymousWhispererPlugin"]
Casino --> Port["EconomyPort"]
HotSeat --> Port
Dilemma --> Port
Dare --> Port
Whisperer --> Port
Economy --> Port
Economy --> Repo["MongoEconomyRepository"]
Repo --> Accounts[("prototypeEconomyAccounts")]
Repo --> Ledger[("prototypeEconomyLedger")]
Casino --> CasinoSessions[("casino sessions")]
HotSeat --> HotSeatSessions[("Hot Seat sessions")]
Dilemma --> DilemmaSessions[("Dilemma sessions")]
Dare --> DareSessions[("Truth/Dare sessions")]
Whisperer --> WhispererSessions[("Whisperer metadata")]
classDiagram
class EconomyPort {
<<core contract>>
+balance(memberNumber)
+grantOnce(operationId, memberNumber, amount, reason)
+reserve(operationId, memberNumber, amount, sessionId)
+adjustReservation(operationId, reservationId, targetCredits)
+settle(operationId, reservationId, result)
+refund(operationId, reservationId)
+forfeitAvailable(operationId, memberNumber)
}
class EconomyService {
+balance()
+grantOnce()
+reserve()
+adjustReservation()
+settle()
+refund()
+forfeitAvailable()
}
class EconomyRepository {
<<persistence port>>
+initialize()
+applyTransaction()
}
class MongoEconomyRepository
class EconomyAccount {
+memberNumber
+availableCredits
+reservedCredits
+lastEntryGrantAt
+schemaVersion
}
class EconomyLedgerEntry {
+operationId
+memberNumber
+sourceGame
+kind
+availableDelta
+reservedDelta
+reasonCode
}
EconomyService ..|> EconomyPort
EconomyService --> EconomyRepository
MongoEconomyRepository ..|> EconomyRepository
MongoEconomyRepository --> EconomyAccount
MongoEconomyRepository --> EconomyLedgerEntry
flowchart LR
Player((Player)) --> Balance([Read shared balance])
Casino([Casino game]) --> Reserve([Reserve credits])
Casino --> Settle([Settle or refund reservation])
PartyGame([Party game]) --> Grant([Award approved game credits])
PartyGame --> Forfeit([Forfeit available credits])
Operator((Authorized operator)) --> Migrate([Run dry-run / approved migration])
Balance --> Account[(Shared account)]
Reserve --> Ledger[(Immutable ledger)]
Settle --> Ledger
Grant --> Ledger
Forfeit --> Ledger
Migrate --> Account
flowchart TD
Start([Game outcome or bid]) --> Validate{Valid bounded operation?}
Validate -->|No| Reject[Return typed rejection]
Validate -->|Yes| Tx[Start Mongo transaction]
Tx --> Existing{Operation ID already recorded?}
Existing -->|Yes, same operation| Replay[Return recorded result]
Existing -->|Yes, conflicting| Conflict[Return conflict; do not mutate]
Existing -->|No| Funds{Sufficient available credits?}
Funds -->|No| Insufficient[Return insufficient funds]
Funds -->|Yes| Update[Update account and insert ledger entry]
Update --> Commit[Commit transaction]
Commit --> Result([Return durable result])
stateDiagram-v2
[*] --> Available
Available --> Reserved: atomic reserve
Available --> Available: insufficient funds / no write
Reserved --> Settled: durable outcome + idempotent settlement
Reserved --> Refunded: void / cancel / recovery policy
Settled --> Settled: duplicate operation ID returns prior result
Refunded --> Refunded: duplicate refund returns prior result
Settled --> [*]
Refunded --> [*]
sequenceDiagram
participant Game as GameService
participant Economy as EconomyService
participant Repo as MongoEconomyRepository
participant Mongo as MongoDB transaction
Game->>Economy: reserve(stableOperationId, member, amount, session)
Economy->>Repo: validate and reserve
Repo->>Mongo: read account + idempotency key
Mongo-->>Repo: account and prior-operation result
alt new valid reservation
Repo->>Mongo: update account + insert ledger atomically
Mongo-->>Repo: committed
Repo-->>Economy: reserved
else retry
Repo-->>Economy: prior result (no second mutation)
else insufficient or conflict
Repo-->>Economy: typed rejection
end
Economy-->>Game: durable result
Game->>Game: persist session transition / settlement marker
credits is canonical; casino may use
“chips” as a temporary display alias for the same account./bot casino balance remains available and may display
“chips” while using the shared credits account.