diff --git a/.cursor/rules/alpha-no-compat.mdc b/.cursor/rules/alpha-no-compat.mdc new file mode 100644 index 000000000..da1808302 --- /dev/null +++ b/.cursor/rules/alpha-no-compat.mdc @@ -0,0 +1,18 @@ +--- +description: Alpha has no installed base; do not treat format breaks as bugs +alwaysApply: true +--- + +# Alpha: no compatibility + +This project is alpha. There is no installed base. Do not preserve, migrate, or dual-read old formats. + +Not bugs: +- Changing a persistence, wire, CLVM, or API shape +- Dropping an old save schema, localStorage key, or envelope version +- Requiring a new session after a format change +- Missing a migration, fallback decoder, or "existing users" path + +Do not propose compatibility shims, version dual-paths, or "this will break saved games" as a finding. + +Still a bug: the **current** encoder and decoder disagree, so a value written now cannot be read now in the same build (refresh, resume, round-trip in this code). Fix that by making today's format consistent, not by keeping an old one. diff --git a/Cargo.toml b/Cargo.toml index daf3153ac..d44cfc4c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ axum = { version = "=0.8.9", features = ["ws"], optional = true } [build-dependencies] chialisp = "0.5.0" clvmr = { version = "=0.17.7" } +serde_json = "=1.0.145" toml = "=0.8.23" [[bin]] @@ -59,7 +60,7 @@ required-features = ["sim-server"] [[bin]] name = "gen-krunk-dict" -path = "src/bin/gen_krunk_dict.rs" +path = "games/krunk/rust/bin_gen_krunk_dict.rs" [lib] name = "chia_gaming" diff --git a/FRONTEND_ARCHITECTURE.md b/FRONTEND_ARCHITECTURE.md index 8dc7c3599..02eab4a29 100644 --- a/FRONTEND_ARCHITECTURE.md +++ b/FRONTEND_ARCHITECTURE.md @@ -415,11 +415,11 @@ resumable-session marker, and tab/reset coordination keys, inside the same-origi trust model described above. The current and only legal envelope schema is `chia-gaming-session` version -`13`. Because the project is +`15`. Because the project is still alpha, every other version is deleted wholesale without decoding or -migration. A decoded v13 record must also satisfy the complete phase-owned +migration. A decoded v15 record must also satisfy the complete phase-owned envelope contract (keyed game membership, game-owned payload/type agreement, -terminal data, and frozen terminal coin list); malformed v13 records are +pending local candidates, terminal data, and frozen terminal coin list); malformed v15 records are deleted rather than partially restored. The boot marker is retained after an incompatible or malformed resumable record is discarded so the failure remains visible at the Resume / Start Over boundary. The `version` field is kept as a @@ -443,7 +443,7 @@ are grouped under those phase-owned payloads: | Field | Type | Purpose | | ------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `version` | `bigint` | Save schema version; currently `13`. | +| `version` | `bigint` | Save envelope version; currently `14`. | | `playerId` | `string` | Stable local hub/player identity for this browser state. | | `sessionId` | `string?` | Stable token linking the hub iframe and game-channel WebSocket. | | `alias` | `string?` | Local hub display alias preference. | @@ -457,7 +457,7 @@ are grouped under those phase-owned payloads: | `hubAlert` | `boolean?` | Whether the Hub tab should show an alert dot. | | `blockchainType` | `'simulator' \| 'walletconnect'?` | Which wallet backend is active or should be reconnected. | | `serializedGameSession` | `Uint8Array?` | Raw binary WASM game-session state via `serialize()`. | -| `gameSessionSchemaVersion` | `bigint?` | Rust-owned schema ID for `serializedGameSession`; currently `4`. Missing or mismatched IDs are unsupported and cleared before deserialization. | +| `gameSessionSchemaVersion` | `bigint?` | Rust-owned schema ID for `serializedGameSession`; currently `6`. Missing or mismatched IDs are unsupported and cleared before deserialization. | | `pairingToken` | `string?` | Locally generated identity for the current peer-session/controller instance. It is persisted so pre-cradle setup or a full session resumes into the same instance, and it correlates Shell transition completion with that instance; it is not protocol authority. | | `sessionPeerId` | `string?` | Public hub peer id of the current opponent, used to rebind `PeerSession` on restore. | | `myHubPlayerId` | `string?` | Last public player id assigned by the hub, used only to detect remapping during resume. | @@ -495,11 +495,11 @@ are grouped under those phase-owned payloads: | `dismissedChannelStatus` | `string \| null` | Last dismissed channel-status notification value. | | `cleanShutdownStarted` | `boolean` | Whether clean shutdown has been requested. | | `betweenHandMode` | `string` | Between-hand overlay state. | -| `betweenHandCompose` | `{ selected_game, game_timeout, proposal_sent, calpoker: { amount }, krunk: { amount }, spacepoker: { unit_size, stack_size } }` | Complete session-owned compose draft. Every registered game draft is present and all amounts are decimal bigint strings. Space Poker persists the exact editable unit and stack independently; the stake is derived as `unit_size * stack_size`. | -| `betweenHandLastTerms` | `SavedHandTerms \| null` | Last agreed hand terms. | -| `betweenHandRejectedOnceTerms` | `SavedHandTerms \| null` | Terms already rejected once, used to avoid repeated automatic retries. | -| `betweenHandPendingRetryTerms` | `SavedHandTerms \| null` | Local proposal terms waiting for retry after a proposal collision. | -| `proposalGroups` | `Array<{ primary_id, member_ids, terms, origin, disposition }>` | Normalized proposal projection. Each group owns its canonical first ID, ordered factory members, one terms object, local/peer origin, and outgoing/incoming-cached/incoming-review/accepted disposition. Member lookup is derived rather than persisted. | +| `betweenHandCompose` | `{ selected_game, game_timeout, proposal_sent, drafts: { calpoker: { amount }, krunk: { amount }, spacepoker: { unitSize, stackSize } } }` | Complete session-owned compose draft. Every registered game draft lives under `drafts`. Amounts are decimal bigint strings. Space Poker persists the exact editable unit and stack independently; the stake is derived as `unitSize * stackSize`. | +| `betweenHandLastHandProposal` | `SavedHandProposal \| null` | Last agreed hand proposal, independent of the compose draft. Null when there is no agreed hand yet. | +| `betweenHandRejectedOnceHandProposal` | `SavedHandProposal \| null` | Hand proposal already rejected once, used to avoid repeated automatic retries. | +| `betweenHandPendingRetryHandProposal` | `SavedHandProposal \| null` | Local hand proposal waiting for retry after a proposal collision. | +| `proposalGroups` | `Array<{ primary_id, member_ids, hand_proposal, origin, disposition }>` | Normalized proposal projection. Each group owns its canonical first ID, ordered factory members, one HandProposal object, local/peer origin, and outgoing/incoming-cached/incoming-review/accepted disposition. Member lookup is derived rather than persisted. | | `waitingStateEnteredAt` | `bigint \| null` | Epoch ms when the channel entered an abandon-eligible waiting state. | | `cleanShutdownGraceStartedAt` | `bigint \| null` | Epoch ms when the clean-shutdown grace timer started. | @@ -518,21 +518,23 @@ two authoritative sources: The pure root reducer returns the next authority and ordered effects. `SessionMachineRuntime` publishes that authority, runs commands (including -`persist-session`), and only then schedules React. Local game commands use the -shared `commitLocalGameAction` boundary: Rust/WASM accepts the command first, -then one machine transition commits the game-owned candidate state and local -turn together. A synchronous rejection therefore cannot enter authority or a -save. `assembleSessionSave` reads -game-owned `handState` only from current machine authority and combines it with -the controller's WASM-origin snapshot at effect execution time. Live game mounts -receive a discriminated hand source containing the real controller; terminal -mounts receive only readonly persisted hand state. There is no controller-owned -feature-state mirror, render-driven save effect, or React/model mirror ref. -Every game reads that hand source through `useInitialGameHandState` exactly once -per keyed mount. The captured value is initialization/restore input only; -subsequent canonical state transitions do not re-decode the source. A new -`handKey` or a cold terminal mount creates the next lifetime and therefore the -next snapshot. +`persist-session`), and only then schedules React. Games dispatch a +`GameIntent`. A command result distinguishes rejection, queueing, and actual +application. Immediate application commits the candidate and local turn in one +machine transition. A queued candidate is persisted separately from canonical +`handState` and projected only for live rendering; the host promotes it when +Rust emits host-only `LocalActionApplied`, or discards it on `MoveRejected` or +`ActionFailed`. A rejected candidate therefore never enters canonical hand +authority. +`assembleSessionSave` reads +game-owned canonical `handState` and separately validated pending candidates +from current machine authority and combines them with +the controller's WASM-origin snapshot at effect execution time. Every package +has one `render(view)` mount. Its `frozen` boolean is a type discriminant: only +the live branch has an intent port. Games decode the current machine-owned hand +state on every render; there is no controller-owned mirror, event stream, +one-shot live snapshot, render-driven save effect, or React/model mirror ref. +A new `handKey` still creates a fresh component lifetime. `SessionController.onSaveNeeded` invokes the same runtime persistence path for ordinary debounced WASM changes. Transaction submission and resubmission remain owned by Rust's `TransactionManager`, not by a frontend transaction field. @@ -546,16 +548,19 @@ instance's coin and protocol presentation together, so there are no separately mutable aggregate current-game fields that can drift across game IDs. A game instance's initial turn comes from Rust's per-game `ProposalAccepted.our_turn` fact; the frontend does not reconstruct it from channel role or factory order. A game -hook computes a candidate state and submits it through `commitLocalGameAction`; -after Rust accepts, the root reducer applies the game-owned state and local-turn -projection atomically. Feature hooks never write controller persistence state -or call persistence directly. +hook computes a candidate state and submits it through `commitLocalGameAction`. +The host either applies it immediately or stores one pending candidate per game +ID until Rust reports application. Pending feature states are projected in +ordered hand-ID order, never replace canonical `handState`, and make that ID +non-actionable until application or rejection. Feature hooks never write +controller persistence state, interpret protocol replay, or call persistence +directly. `GameSettled` retires only its own game ID from the slice's active set. This allows separate members of an atomic factory group to settle independently without removing the still-live member from persistence or presentation. Proposal state is one normalized `proposalGroups` collection. Each entry owns -its canonical first ID, ordered members, one terms object, origin, and explicit +its canonical first ID, ordered members, one HandProposal object, origin, and explicit UI/lifecycle disposition. Member-ID lookup scans this collection as a pure derivation; there are no per-ID terms/group maps or parallel outgoing/accepted ledgers to rebuild on restore. Product policy permits at most one outgoing local @@ -564,10 +569,12 @@ same entry changes to `accepted`, preserving terms and ordered Krunk membership across both `ProposalAccepted` notifications. An `InsufficientBalance` removes the affected group atomically; successful Krunk members still settle independently, and the accepted entry is removed only after the hand is fully -settled. Schema version 12 also makes +settled. The current v15 envelope makes `gameInstances` plus `lastDisplayedGameId` the only persisted game protocol -presentation and stores the canonical `GameProtocolPresentation` discriminant. -Under the alpha no-migration policy, version 12 and all other incompatible +presentation, stores the canonical `GameProtocolPresentation` discriminant, +and keeps validated pending local candidates separate from game-owned +`handState`. +Under the alpha no-migration policy, all incompatible records are deleted rather than translated from aggregate current-game fields. #### Delivery-critical saves @@ -658,19 +665,23 @@ React-only copy that restore has to reconstruct by hand. `SessionModel` is the generic shell boundary. It owns the canonical keyed protocol presentation and carries `handState` only as an opaque `PersistedGameState { gameType, version, state }` envelope. The shell does not -interpret the payload. Calpoker, Space Poker, and Krunk each expose exactly one -feature-owned pure registration. That registration owns the state codec, proposal -encoding/decoding, term validation/equality, compose defaults, persisted term -extras, lifecycle defaults, and durable-state reduction. -`GAME_REGISTRATIONS` is the single pure keyed source and derives display -metadata; its mapped type is exhaustive over `RegisteredGameType`. React mounts -live in the separate exhaustive `GAME_MOUNTS` registry so the pure registration -graph does not import React. Rendering indexes that registry directly—there are -no duplicate game arrays or switch dispatchers—and the dependency direction -does not cycle. All three codecs support live restore. The codec's explicit -`canRemountFinished` capability is `true` for Cal Poker, Space Poker, and Krunk, -so cold finished-session rendering validates the game-owned payload before -remounting instead of inferring support from payload presence alone. +interpret the payload. Production games export a `GamePackage`; the host +contract, layout, and APIs are in [`GAME_WRITING_GUIDE.md`](GAME_WRITING_GUIDE.md). +`games/registry.json` is the only catalog. First-member initial validation +puzzle hashes live in `gameIdentities.ts` (warmup fills the table by running +factories with representative valid parameters; Active completes leftover +probes). The JS session model and saves store catalog keys (`calpoker`, +`spacepoker`, `krunk`). `packageFor` accepts those keys only. The puzzle hashes +are protocol ids at the WASM propose/notify boundary (`protocolIdForCatalog` +out, `catalogGameTypeFromWire` in). WASM and factory probes start on page load +so the protocol id table is filled before play. Each game may ship +`games//ui/styles.css`; the registry generator imports those files into +the player-app stylesheet, and Tailwind scans `games/` for utility classes. +Core never branches on Calpoker/Krunk/Space Poker when composing or reviewing a +proposal. All three codecs support live restore. The codec's explicit +`canRemountFinished` capability is `true` for Cal Poker, Space Poker, and +Krunk, so cold finished-session rendering validates the game-owned payload +before remounting instead of inferring support from payload presence alone. **Game dashboard (status banner):** The compact strip above the Game tab content (`GameDashboard` in `Shell.tsx`) is selector-driven. `selectGameDashboardView` @@ -749,6 +760,12 @@ leftover preferences. #### Boot state machine +On page load, `index.tsx` starts WASM bootstrap in parallel with React: +fetch the module and `.hex`/`.dat` presets, then probe each production +factory one at a time (yielding between packages). Handshake uses that +already-loaded module for BLS identity only. Protocol game identities are +bound when the channel becomes `Active`, from the warmed cache. + On page load, `Shell.tsx` runs a boot sequence that determines which dialog (if any) to show before the app becomes interactive. The initializer never claims the tab lease (that would fence other tabs) and never blocks the dialog @@ -1380,9 +1397,10 @@ The cohesive session modules own those responsibilities: - `sessionMachineEffects.ts` enforces authority → commands/save → React ordering; saves combine WASM cradle bytes with machine-owned `handState`. - `sessionMachineInterpreter.ts` performs controller calls, timers, - persistence, gameplay emission, and async enrichment. + persistence, and async enrichment. - `sessionMachinePersist.ts` assembles and writes snapshots at effect time. -- `gameSessionEvents.ts` normalizes raw notification payloads. +- `gameSessionEvents.ts` parses session-owned terminal and coin payloads from WASM notifications. +- `lib/gameProposalCodec.ts` encodes proposal factory parameters and decodes `ProposalMade` envelopes; `session/incomingProposal.ts` assembles `ProposalGroupModel`. The controller still waits for its normal macrotask boundary, then drains one active FIFO to quiescence so synchronously re-entrant WASM effects enter the @@ -1394,35 +1412,32 @@ across unmounts and reloads. ### Game Components -The active game UI is rendered inside `GameSession` based on the current game -type. `front-end/src/lib/gameRegistry.ts` holds the pure feature registrations -for California Poker (`calpoker`), Space Poker (`spacepoker`), and Krunk -(`krunk`). `front-end/src/lib/gameMountRegistry.tsx` separately and -exhaustively registers their lazy live/frozen React mounts. - -`CalpokerHand` receives gameplay events via an RxJS observable and submits moves -through the shared Rust-first local-action boundary. +The active game UI is rendered inside `GameSession` from the selected +`GamePackage`. `front-end/src/lib/gameRegistry.ts` looks packages up by catalog +key only. `front-end/src/lib/gameMountRegistry.tsx` creates one +boolean-discriminated mount view for active, in-session terminal, and +cold-restored hands. The first generated member's initial validation +puzzle hash is the protocol id at the WASM propose/notify boundary +(`protocolIdForCatalog` out, `catalogGameTypeFromWire` in). +`front-end/src/lib/gameProposalCodec.ts` is the inverse pair for that boundary: +`encodeGameProposalParameters` on the way out and `decodeProposalMadeTerms` on +the way in. Each package still owns its `factoryParameters` codec and +`decodeHandProposal`. + +Each hook decodes the current machine-owned hand state on every render. It +submits only `GameIntent` values through the shared Rust-first local-action +boundary. Space Poker keeps its hand history and terminal presentation inside `useSpacepokerHand`. A betting-round fold, a showdown no-reveal concession, and a revealed showdown remain distinct displays. The hook attributes a terminal opponent action only when the current readable handler proves it; a -`GameSettled` notification alone does not imply that either player folded. Its -terminal reveal, concession, and fold entries are committed only after Rust -accepts the local command. They are removed and the playable hand restored only -when a later matching game-scoped -`MoveRejected`, `game-action-error`, or context-bearing Rust `ActionFailed` -event reports that `makeMove` or `acceptSettlement` failed. Rust preserves that -context when a potato-gated queued move or settlement fails during a later -flush; unscoped failures are never attributed to a hand. A failed automatic -reveal or concession enters an explicit recovery state and waits for a user retry -or authoritative update; it never resubmits on a React effect rerun. Generic -terminal errors and non-voluntary settlements replace optimistic terminal state -with the authoritative generic presentation. A revealed presentation survives -only its voluntary settlement acknowledgement, never a timeout, slash, or other -settlement outcome. This is UI state only: the session -controller and Rust `GameSettled` outcome remain the authority, and the game -component never observes the chain itself. +`GameSettled` notification alone does not imply that either player folded. +Terminal reveal, concession, and fold candidates commit only when Rust reports +that it applied the intent. `MoveRejected` leaves gameplay state unchanged and +records a visible package error. There is no game-owned rollback, +retry-recovery, or protocol-redo subsystem; unexpected infrastructure failures +are shown by shared host error UX, and the game never observes the chain itself. The `useCalpokerHand` hook manages the five-step protocol: @@ -1431,9 +1446,10 @@ The `useCalpokerHand` hook manages the five-step protocol: - **Move 2** (auto) — final reveal - **Outcome** — parsed from the opponent's final move into a `CalpokerOutcome` -Game components are **remounted from scratch for every hand** via React key -(`key={session.handKey}`). This ensures no stale state accumulates between -hands. +Game components are **remounted from scratch for every hand**. The host mount +registry applies `session.handKey` as the React key after the game returns its +root element. Games do not manage this lifecycle policy themselves. This +ensures no stale state accumulates between hands. What the game UI does **not** know about: @@ -1447,8 +1463,8 @@ What the game UI does **not** know about: `useGameSession` normalizes each WASM notification into a typed machine event. `sessionMachineNotifications.ts` then reduces it and emits ordered effects into -the scoped queues, gameplay stream, controller, persistence path, or async -enrichment boundary: +the scoped queues, controller, persistence path, or async enrichment boundary. +Normalized game inputs update `model.game.handState` before React renders: ### Channel notification queue @@ -1490,26 +1506,22 @@ the notification reducer and never forwarded raw to the game UI: - `ProposalMade` — one notification per factory group; carries the first ID and always-non-empty ordered `group_ids` (singleton ⇒ `[id]`), and triggers group auto-accept +- `ProposalAccepted` — starts the accepted hand, initializes its durable game + state, and advances `handKey` -### Gameplay events (forwarded to game UI via observable) +### Normalized game inputs -These are the normal flow of play, forwarded to the active game UI component -via the `gameplayEventSubject` RxJS stream: +The machine applies exactly five package-facing inputs: -- `ProposalAccepted` — a new game is starting (also clears stale - `proposal-rejected` entries from the game queue) -- `OpponentMoved` — the opponent made a move (with readable data and - `moverShare`, our share after that move / on timeout from it) -- `GameMessage` — advisory data (e.g. Alice revealing cards to Bob early) -- `MoveRejected` — a recoverable delayed rejection with game id, tag, and - message; game hooks roll back only the matching Rust-accepted local action -- `Settled` — `{ gameId, outcome, ourShare }` from `GameSettled`; dual-delivered - to the session banner and the active game hook via `gameplayEvent$` -- `GameError` — non-settlement terminals (`EndedCancelled`, `EndedError`, - `InsufficientBalance`) and unknown settlement outcomes +- `hand-started` +- `opponent-moved` +- `game-message` +- `move-rejected` +- `hand-ended` -Legacy `GameStatus` slash/timeout `Ended*` kinds are no longer forwarded to -gameplay hooks; settlements use `GameSettled` only. +There is no separate game-status event or action echo. Turn, replay, timeout, +on-chain, proposal, removal, abandonment, and freezing transitions remain in +the host model. `ActionFailed` and controller exceptions go to shared error UX. ## Single-Hand Enforcement @@ -1559,7 +1571,7 @@ protocol's asynchronous nature and cannot be deferred to JS: the definitive state). WASM emits one `ProposalCancelled { reason: SupersededByIncoming }` for each removed group, keyed by its first ID. -2. **`PeerProposalPending`** — When JS calls `propose_game` while an +2. **`PeerProposalPending`** — When JS calls `propose_games` while an unresolved peer proposal exists in `proposed_games`, WASM rejects immediately with `ProposalCancelled { reason: PeerProposalPending }`. This prevents silently cancelling the peer's proposal as a side effect @@ -1589,16 +1601,18 @@ not to limit concurrency. | `front-end/src/components/GameSession.tsx` | Game session UI: header, coin status, game area, overlays | | `front-end/src/hooks/useGameSession.ts` | Thin React boundary: controller/runtime setup, host subscription, typed dispatch, selector projection | | `front-end/src/lib/session/sessionMachine*.ts` | Root dispatcher plus cohesive channel, between-hand, proposal, durable-game, notification, command, effect, runtime, and persistence modules | -| `front-end/src/lib/session/persistence*.ts` | Canonical strict-v13 phase decoder plus primitive, between-hand/proposal, and phase-payload codecs; accepted records always produce a normalized `SessionModel` | -| `front-end/src/lib/session/sessionSnapshot.ts` | Canonical `SessionModel` → v13 presentation snapshot encoder | -| `front-end/src/lib/gameRegistry.ts` | Exhaustive pure feature registration and game-owned codec/terms/compose dispatch | -| `front-end/src/lib/gameMountRegistry.tsx` | Exhaustive React live/frozen mount registration | -| `front-end/src/features/calPoker/useCalpokerHand.ts` | Calpoker hook: five-step protocol, card parsing, move submission | +| `front-end/src/lib/session/persistence*.ts` | Canonical strict-v15 phase decoder plus primitive, between-hand/proposal, and phase-payload codecs; accepted records always produce a normalized `SessionModel` | +| `front-end/src/lib/session/sessionSnapshot.ts` | Canonical `SessionModel` → v15 presentation snapshot encoder | +| `front-end/src/lib/gameRegistry.ts` | Catalog-key package lookup and game-owned codec/terms/compose dispatch | +| `front-end/src/lib/gameProposalCodec.ts` | Symmetric proposal encode/decode at the WASM `propose_games` / `ProposalMade` boundary | +| `front-end/src/lib/gameMountRegistry.tsx` | One frozen/live discriminated mount dispatched through the selected package | +| `games/calpoker/ui/useCalpokerHand.ts` | Calpoker hook: five-step protocol, card parsing, move submission | | `front-end/src/hooks/SessionController.ts` | WASM bridge (`SessionController` class): message delivery, block data, event queue, `getWasmFields()` for persistence | -| `front-end/src/hooks/WasmStateInit.ts` | WASM initialization: load binary, deposit .hex files, create cradle | +| `front-end/src/hooks/WasmStateInit.ts` | WASM bootstrap: page-load binary/preset fetch, background factory warm, create cradle | +| `front-end/src/lib/gameIdentities.ts` | Factory warmup and the catalog↔hash table used at the WASM propose/notify boundary | | `front-end/src/hooks/blobSingleton.ts` | Singleton management: `getOrCreateSessionController` / `destroySessionController`; restore path for session persistence | | `front-end/src/services/PeerSession.ts` | Per-session peer state: session ID, peer ID, liveness, message buffering/routing, send methods | -| `front-end/src/hooks/save.ts` | v13 cache/write and live/terminal lifecycle facade | +| `front-end/src/hooks/save.ts` | v15 cache/write and live/terminal lifecycle facade | | `front-end/src/hooks/saveCoordination.ts` | Resume markers, active-tab lease, and cross-tab persistence fencing | | `front-end/src/hooks/saveHardReset.ts` | Hard-reset and WalletConnect browser-storage cleanup | | `front-end/src/hooks/savePreferences.ts` | Local preference encoding and decoding | diff --git a/GAME_LIFECYCLE.md b/GAME_LIFECYCLE.md index cab833857..71d9480eb 100644 --- a/GAME_LIFECYCLE.md +++ b/GAME_LIFECYCLE.md @@ -15,13 +15,16 @@ see `OVERVIEW.md`. For on-chain dispute resolution, see `ON_CHAIN.md`. Games are initiated through a propose/accept flow: -1. **Propose:** The caller submits one group request containing `game_type`, - game-specific `parameters`, and one shared `timeout`. Both peers run the same - deterministic factory, which produces the ordered game records for the - group. The potato holder sends one `BatchAction::ProposeGroup`; both sides - record all produced games in `proposed_games`. The receiver gets one - `ProposalMade` notification for the group, with the member IDs in factory - order; the proposer does not. +1. **Propose:** The caller submits one group request containing `game_type` + (the first generated member's initial validation puzzle hash, not a factory + hash or package name), game-specific `parameters`, and one shared `timeout`. + Both peers run the same deterministic factory, which produces the ordered + game records for the group. The potato holder sends one + `BatchAction::ProposeGroup`; both sides record all produced games in + `proposed_games`. The receiver gets one `ProposalMade` notification for the + group, with the member IDs in factory order; the proposer does not. + `ProposalMade` includes the canonical parameter bytes so the UI can decode + terms through the selected package. 2. **Accept:** The receiver (or proposer on a subsequent potato) sends `BatchAction::AcceptProposal` actions for every member in the same batch. Both sides instantiate every referee and game handler, moving the group into @@ -33,10 +36,11 @@ Games are initiated through a propose/accept flow: ### Receiver-Side Proposal Validation -When an incoming `ProposeGroup` is processed, the receiver first runs its -registered factory with the request's `game_type` and exact `parameters`. The -wire member list must be non-empty and have the same ordered cardinality as the -factory result. Each wire member must match the corresponding canonical factory +When an incoming `ProposeGroup` is processed, the receiver first looks up the +factory by the request's hash `game_type`, runs it with the exact `parameters`, +and requires that the first returned record's `initial_validation_program_hash` +equals that `game_type`. The wire member list must be non-empty and have the +same ordered cardinality as the factory result. Each wire member must match the corresponding canonical factory record: sender/receiver contributions, amount, `sender_goes_first`, initial commitments, fixed handlers' derived role, and validator commitment. Any failure rejects the batch (triggering rollback and go-on-chain). diff --git a/GAME_WRITING_GUIDE.md b/GAME_WRITING_GUIDE.md new file mode 100644 index 000000000..c85f50456 --- /dev/null +++ b/GAME_WRITING_GUIDE.md @@ -0,0 +1,563 @@ +# How to Add a Game + +This guide explains the pieces you need to add a game and how they fit +together. Start with the package layout and the step-by-step checklist. The API +reference near the end is useful when you are implementing each file. + +A game has three main parts: + +1. **CLVM rules** define valid moves and protect both players if a dispute goes + on-chain. +2. **A small Rust module** loads the compiled CLVM into the game engine. +3. **A TypeScript/React UI** lets players propose a hand, play it, and restore + it after a refresh. + +You do not need to understand every part of the player application. Game code +uses the public interfaces in [`games/host/index.ts`](games/host/index.ts) and +[`games/host/ui.tsx`](games/host/ui.tsx). Keep your game behind those +interfaces so it remains independent of this particular frontend. + +If state channels are new to you, read [`OVERVIEW.md`](OVERVIEW.md) first. For +the detailed CLVM function signatures, use +[`clsp/handler_api.md`](clsp/handler_api.md). + +## Start from an existing game + +The fastest way to begin is to copy the game that is closest to what you are +building: + +- [`games/calpoker`](games/calpoker) is the simplest complete example. Its + factory creates one game. +- [`games/spacepoker`](games/spacepoker) shows a game with several rounds and + more substantial UI state. +- [`games/krunk`](games/krunk) shows a factory that creates two linked games + from one proposal. +- [`games/debug`](games/debug) is only for protocol tests. It does not have a + production UI. + +## Directory structure + +Put the new game in `games//`, where `` is a short lowercase name +such as `calpoker`. + +```text +games// + clsp/ + factory.clsp # Creates the initial game program and state + onchain/ # Checks moves during an on-chain dispute + *_generate.clinc # Handles moves while the game is off-chain + rust/ + mod.rs # Loads the compiled factory for the Rust engine + tests/ # CLVM, handler, validator, and simulator tests + ui/ + handProposalForm.tsx # Form used to propose a new hand + handProposal.ts # Proposal validation and factory parameters + serialize.ts # Saved UI state and state transitions + play.tsx # Live and finished-hand React views + styles.css # Optional game-specific styles +``` + +The frontend catalog is generated, so do not create `ui/index.ts`. Each UI +file has a conventional export that the generator discovers: + +- `handProposal.ts` has a default export containing the game registration. +- `handProposalForm.tsx` exports `HandProposalForm`. +- `play.tsx` exports `play`. + +The generator passes those three exports through `defineGamePackage`. This is +the compile-time boundary that proves the proposal draft, state, feature state, +factory parameters, form, and mount belong to one coherent package. The +generated keyed registry exposes a non-generic runtime facade; game-specific +types are not cast to a fictitious broad package type. + +## Step 1: Register the game + +Add the key to [`games/registry.json`](games/registry.json): + +- Use the `production` list for a playable game with a UI. +- Use the `test` list for a game that exists only in automated tests. + +That is the only catalog you edit by hand. The build generates the Rust +registration, frontend imports, test aggregation, and factory preset list. + +Two identifiers appear in the code: + +- The **catalog key** is the readable name from `registry.json`. The frontend + uses it in saves and when choosing a UI package. +- The **protocol ID** is the first generated game's initial validation puzzle + hash (`initial_validation_program_hash`). Peers use that puzzle hash to + identify the game on the wire. It is not a hash of the factory code. + +Normally your game code only deals with the catalog key. The host converts +between the key and protocol ID at the WASM boundary. + +## Step 2: Implement the CLVM rules + +The factory receives the parameters for a proposed hand and returns the game +or games that the peers will run. It must be deterministic: both peers run the +same factory with the same parameters and must get the same result. + +Most factories create one game. A factory may create several games that must +be accepted or cancelled together; the code calls these an atomic group. +Krunk is the reference example for that case. + +Starting-player policy belongs to each factory record, not necessarily to one +proposal-wide flag. Krunk emits two records with opposite +`sender_goes_first` values so each player picks a word once. If a future game +makes starting order a user-negotiated term, include it in that package's +normalized proposal, description, equality, and persistence. If it is derived +from session role, validate the encoded parameter against the supplied decode +context instead of displaying it as a term. + +Each game returned by the factory includes its starting state, move handlers, +and validation programs. See +[the factory return format](clsp/handler_api.md#game-factory) for the exact +fields. + +During play, the engine uses: + +- A **my-turn handler** to turn a local UI action into the next move. +- A **their-turn handler** to read and apply the opponent's move. +- **Validators** to reject moves that do not follow the rules. +- An optional **message parser** for game messages that update the UI without + changing whose turn it is. + +A handler can reject a local action with an error tag and message. The UI +receives that as `MoveRejected`. A validator returning no valid result means +the move is invalid and can be used as evidence in an on-chain dispute. + +Read [`HANDLER_GUIDE.md`](HANDLER_GUIDE.md) for an explanation and worked +examples. Use [`clsp/handler_api.md`](clsp/handler_api.md) for exact argument +and return shapes. [`CLVM_DOS.md`](CLVM_DOS.md) covers cost and size limits. + +## Step 3: Add the Rust loader + +The Rust engine cannot execute a `.clsp` source file directly. Implement +`games//rust/mod.rs` so it can load the compiled factory: + +- `prepared_factory(allocator)` returns the factory used for real proposals. +- `probe_parameters(allocator)` returns one representative, valid parameter + value. During registration, the engine runs the factory with those parameters + and reads the first returned game's initial validation puzzle hash. That is + the protocol ID; the factory itself is never hashed as an identifier. + +For most games, this module only loads compiled hex. It should not duplicate +the game rules; those remain in CLVM. Krunk is an unusual example because its +loader also supplies a compiled dictionary tree. + +Add handler and validator tests under `games//rust/tests/`. Use +[`SIMULATOR_TESTING.md`](SIMULATOR_TESTING.md) when a test needs the blockchain +simulator. + +## Step 4: Define how a hand is proposed + +The proposal flow has three representations: + +```text +editable form draft → HandProposal → CLVM factory parameters +``` + +Keeping these representations separate makes each boundary clear: + +- The **draft** is temporary form state. It may be incomplete or invalid while + the player is typing. +- A **`HandProposal`** is a complete, validated offer sent to the other player. + Every proposal includes `gameType`, both players' contributions, and a + timeout. A game can add fields of its own. +- **Factory parameters** are the CLVM value passed to the game factory. + +Implement the React form in `handProposalForm.tsx`. It receives the current +draft, an `onChange` callback, and an `onSubmit` callback through +`HandProposalFormProps`. Export it as: + +```ts +export function HandProposalForm(props: HandProposalFormProps) { + // ... +} +``` + +### Proposal form API + +The complete package-facing form contract is: + +```ts +interface HandProposalFormProps { + draft: TDraft; + disabled: boolean; + maxPerHandMojos: bigint | null; + onChange: (update: Partial) => void; + onSubmit: () => void; +} +``` + +- `draft` is the current game-specific draft. Treat it as immutable. +- `disabled` is true after submission while the host is preventing another + proposal. Disable every editable control and submit action when it is true. +- `maxPerHandMojos` is the largest currently available contribution per player, + in mojos. `null` means the host cannot provide a balance-derived limit; it + does not make an otherwise invalid draft valid. +- `onChange(update)` sends a partial draft update to the host. The host passes + the current draft and this update to `draft.update`; the form must not assume + that a shallow merge is sufficient. +- `onSubmit()` asks the host to submit. The host calls `draft.toHandProposal`, + validates the result and the balance limit again, and does nothing if those + checks fail. The form may use normal form submission or call this callback + from its submit button. + +The host owns the game selector and `gameTimeout`; they are deliberately absent +from this interface. The game form owns only game-specific draft fields. A form +must not send a proposal or call protocol APIs itself. + +Implement the conversion and validation in `handProposal.ts`. Its registration +must provide: + +- `draft.default` to create an initial form value. +- `draft.update` to apply a form change. +- `draft.toHandProposal` to produce a valid proposal, or `null` if the draft is + not ready to submit. +- `draft.fromHandProposal` to repopulate the form from an existing proposal. +- `validateHandProposal` to validate a complete proposal. +- `handProposalsEqual` to compare two proposals. +- `describeHandProposal` to write a short, readable summary for the receiving + player. +- `lifecycle.proposalSenderGoesFirst` to say which player takes the first turn. + +Use `equalHandProposalBase` when your equality check only needs to add +game-specific fields to the common proposal comparison. + +The same registration translates between a `HandProposal` and CLVM: + +- `toFactoryParameters(handProposal, iStarted)` creates the typed parameter + object for an outgoing proposal. +- `factoryParameters.encode` converts that object into a CLVM program. +- `factoryParameters.decode` safely parses an untrusted CLVM program. +- `decodeHandProposal(base, params, context)` reconstructs and validates the + proposal received from the peer. + +The host provides `readClvmProgram`, `readClvmAtom`, `readClvmFlag`, and +`readClvmList` to help write strict decoders. + +### Proposal and factory-parameter decoder API + +Common proposal terms are always supplied separately from the game factory +parameters: + +```ts +interface HandProposalBase { + myContribution: bigint; + theirContribution: bigint; + gameTimeout: bigint; +} + +type HandProposal = HandProposalBase & { + gameType: string; + // A package may add validated game-specific fields. +}; + +interface FactoryParameterCodec { + decode(value: unknown): TParams | null; + encode(params: TParams): Program; +} + +interface HandProposalDecodeContext { + origin: 'local' | 'peer'; + iStarted: boolean; + expectedSenderGoesFirst: boolean; +} + +interface ProposalCodec { + factoryParameters: FactoryParameterCodec; + toFactoryParameters(handProposal: HandProposal, iStarted: boolean): TParams; + decodeHandProposal( + base: HandProposalBase, + params: TParams, + context: HandProposalDecodeContext, + ): HandProposal | null; +} +``` + +`toFactoryParameters` receives validated proposal terms and whether this client +started the session. It returns the typed game-specific value consumed by +`factoryParameters.encode`. `encode` returns the CLVM `Program` passed to the +factory; the host handles serialization. + +Decoding is intentionally two-stage: + +1. `factoryParameters.decode(value)` receives untrusted data, normally + serialized CLVM bytes. It must validate the complete CLVM shape and every + value, returning typed parameters or `null`. Malformed peer data is expected + at this boundary and must not throw. +2. `decodeHandProposal(base, params, context)` combines the already-decoded + common terms with the typed parameters. It must reject contradictions + between duplicated values, validate any proposer-relative policy represented + by its parameters, add the package's `gameType` and game-specific proposal + fields, run the complete proposal validation, and return `null` on any + mismatch. + +The host verifies that a non-null proposal has the registration's catalog +`gameType`. Do not trust a type assertion or silently repair inconsistent peer +data. Incoming `ProposalMade` notifications must contain an explicit positive +timeout and explicit factory `parameters`. Missing fields are decode failures; +`initial_state` is factory output and is never a substitute for parameters. + +The strict CLVM readers have these exact contracts: + +```ts +readClvmProgram(value: unknown): Program | null; +readClvmAtom(program: Program): bigint | null; +readClvmFlag(program: Program): boolean | null; +readClvmList(program: Program, length: number): readonly Program[] | null; +``` + +- `readClvmProgram` accepts only a `Uint8Array` containing exactly one + canonically serialized program, with no trailing bytes. +- `readClvmAtom` accepts only a value convertible to a CLVM integer. +- `readClvmFlag` accepts exactly integer `0` or `1`. +- `readClvmList` accepts a proper nil-terminated list with exactly `length` + members; dotted tails are rejected. + +These helpers validate representation, not game rules. The decoder must still +check positivity, ranges, cross-field relationships, and consistency with +`HandProposalBase`. Test a valid encode/decode round trip, malformed bytes, +wrong list lengths and shapes, invalid values, and another game's parameter +encoding. + +## Step 5: Save and update the UI state + +The protocol state in Rust is not enough to restore every detail of a React +UI. For example, a card game may need to save revealed cards or the currently +selected cards. Define that game-owned UI state in `serialize.ts`. + +Create `stateCodec` with `defineGameStateCodec`. The codec: + +- Identifies the state with your catalog key and a version. +- Checks unknown data with `isState`. +- Encodes and decodes `PersistedGameState`. +- Lists the game IDs represented by the state. +- Says whether a finished hand can be shown again after a refresh with + `canRemountFinished`. + +Do not accept malformed saved data by casting it. `decode` is a trust boundary, +so `isState` must verify every field your UI relies on. + +Also implement the three `durableState` operations: + +- `initialize` creates one keyed hand from the normalized `hand-started` input. +- `reduceInput` applies `opponent-moved`, `game-message`, `move-rejected`, and + `hand-ended`. +- `applyFeatureState` places an accepted local feature state into the hand + envelope. This matters for a multi-ID package such as Krunk. + +Keep the reducer pure. Given the same current state and event, it must return +the same next state. + +If your `HandProposal` has extra fields, implement +`persistence.encodeExtras` and `persistence.decodeExtras` in +`handProposal.ts`. This saves the proposal itself; `stateCodec` saves the +in-progress or finished UI state. + +## Step 6: Build the play UI + +Implement `play.tsx` and export a `GameMountRegistration` named `play`. It has +one `render(view)` function. Every render receives the current decoded-state +envelope, ordered and active IDs, accepted amounts, terminal results, names, and +`frozen`. + +`frozen` is the type discriminant: + +- `frozen: false` includes the typed intent port. +- `frozen: true` has no protocol capability. + +The host applies proposal terms through `durableState.initialize`; the mounted +hand never receives proposal, group, abandonment, connection, or on-chain +lifecycle objects. + +These functions return React elements; they are not imperative drawing +callbacks. React may call them again when session state changes, then preserves +the existing component state and DOM where the element type and key are +unchanged. The host applies its `handKey` to the returned element, which +intentionally starts a fresh component lifecycle for each new hand. Game code +does not need to add a React key or manage this lifecycle itself. + +Use `requireLiveGameHandSource` before dispatching an intent. The complete +outgoing contract is: + +```ts +type GameIntent = + | { type: 'update-local-state'; state: TState } + | { type: 'make-move'; gameId: string; readable: Program | null; state: TState } + | { type: 'accept-settlement'; gameId: string; state: TState } + | { type: 'cheat'; gameId: string; moverShare: bigint; state: TState }; +``` + +- `update-local-state` persists game-owned UI state without a protocol command. + It is currently available only to a single-ID hand; multi-ID packages update + a member through a protocol intent. +- `make-move` asks the local CLVM handler to process `readable`. `null` means + CLVM nil. `state` is the candidate game-owned feature state for `gameId`. +- `accept-settlement` accepts the result for `gameId` and carries the candidate + feature state to persist on acceptance. +- `cheat` deliberately invokes the diagnostic illegal-move path with a + mojo-denominated `moverShare` and candidate feature state. It is not a normal + gameplay fallback. + +The host keeps command execution and candidate state atomic. If Rust applies the +action immediately, the candidate commits immediately. If Rust queues it, the +host persists the candidate separately from canonical `handState` and projects +it for live rendering until Rust reports that the action was applied. The game +does not observe whether this delay involved potato acquisition, on-chain +progress, or protocol redo. + +`move-rejected` discards the pending projection without committing it. +Unexpected `ActionFailed` errors discard the pending candidate and go to shared +host error UX. For an applied intent, the host commits `state` through +`durableState.applyFeatureState(currentHand, gameId, state)`. Therefore `state` +is the state of the addressed game feature; it is the whole hand only when the +package's hand and feature state are the same type. + +The complete incoming contract is: + +```ts +type ProposalGroupOrigin = 'local' | 'peer'; + +interface GameHandInitialization { + id: string; + gameIds: readonly string[]; + iStarted: boolean; + canAct: boolean; + origin: ProposalGroupOrigin; + handProposal: HandProposal; +} + +type GameInput = + | { type: 'hand-started'; init: TInit } + | { + type: 'opponent-moved'; + gameId: string; + readable: Uint8Array; + moverShare: string; + } + | { type: 'game-message'; gameId: string; readable: Uint8Array } + | { type: 'move-rejected'; gameId: string; tag: string; message: string } + | { type: 'hand-ended'; gameId: string; terminal: GameTerminalModel }; +``` + +- `hand-started` initializes or extends one accepted hand. `init.id` is the game + ID whose acceptance triggered this input; `gameIds` is the authoritative + ordered membership and may contain more than one ID. A multi-ID group can + receive this input as its member acceptances arrive, so `initialize(current, +input)` must preserve already-initialized member state. `iStarted` identifies + the local session initiator, `canAct` is the initial local action capability, + `origin` says whether the accepted proposal was local or peer-authored, and + `handProposal` contains the validated accepted terms. These are normalized + initialization facts, not proposal-lifecycle events. +- `opponent-moved` addresses one member of the hand. `readable` is the + serialized CLVM readable returned by the opponent-move handler. + `moverShare` is a decimal mojo string because it originated at the WASM + boundary. +- `game-message` carries serialized advisory readable data for one member. It + does not itself imply a move, turn change, or protocol-state transition. +- `move-rejected` reports an expected local-handler rejection for one member. + `tag` is the game-defined machine-readable category and `message` is its + displayable explanation. The candidate state from the rejected intent was not + committed. A game with expected validation feedback, such as Krunk, should + present it as domain feedback. A game that considers rejection unreachable + should still display the supplied error rather than silently ignoring it; it + must not add retry or redo behavior. +- `hand-ended` supplies the normalized terminal model for one member. Multi-ID + hands receive independent terminal inputs as their members finish. + +`hand-started` is the only input allowed to initialize a null durable state. +Every other input requires a valid current state, and every package transition +must produce a state accepted by its codec. The host treats violations as +internal errors rather than silently dropping the input. + +The exact terminal payload is: + +```ts +type SettlementOutcome = + | 'accept_settlement' + | 'settled_cleanly' + | 'opponent_timed_out' + | 'forfeited_skipped_reveal' + | 'lost' + | 'forfeited_we_accepted' + | 'we_accepted' + | 'attempt_to_move_failed' + | 'timed_out_waiting_for_our_move' + | 'slashed_opponent' + | 'opponent_slashed_us' + | 'opponent_cheated'; + +type GameTerminalType = + | 'none' + | 'settled' + | 'insufficient-balance' + | 'ended-cancelled' + | 'game-error'; + +interface GameTerminalModel { + type: GameTerminalType; + outcome: SettlementOutcome | null; + label: string | null; + myReward: string | null; + rewardCoinHex: string | null; +} +``` + +`outcome` is the normalized protocol settlement outcome when one exists. +`myReward` is a decimal mojo string, and `rewardCoinHex` is the reward coin ID +as hexadecimal. `label` is host-provided presentation text. A game should +branch on structured `type` and `outcome`, not parse `label`. + +These inputs update the machine-owned hand model before React renders. There is +no event observable or local echo. Protocol turn, timeout, replay, spending, +freezing, proposal lifecycle, removal, abandonment, transport, persistence, +and shared error reporting remain host-owned. + +The host also provides shared UI helpers through `games/host`, including +`AmountInput`, `useGameHost`, amount formatting, settlement labels, and +`GameTerminalModel`. + +## Import boundaries + +Game UI code and game tests may import: + +- `games/host`, usually through `../../host` +- Other files inside the same game package +- `react` and `clvm-lib` + +They must not import from `front-end/` or use the frontend `@/` alias. This +keeps a game portable and prevents circular dependencies. The isolation test +in +[`game_package_isolation.test.ts`](front-end/src/lib/tests/game_package_isolation.test.ts) +enforces this rule. + +The following are frontend implementation details, not APIs for games: + +- Raw WASM payload types such as `GameStatus`, `LocalActionApplied`, + `ActionFailed`, and `ProposalMade` +- [`front-end/src/lib/gameProposalCodec.ts`](front-end/src/lib/gameProposalCodec.ts) +- The session model, `useGameSession`, and the catalog-to-protocol-ID mapping + +## Testing checklist + +Before considering the game complete, check that: + +- The factory returns the expected game records for valid parameters. +- Invalid factory parameters and invalid moves are rejected. +- Both players derive the same initial game. +- Handler and validator tests cover each legal move and important illegal + moves. +- The proposal form converts to and from `HandProposal` correctly. +- Factory parameter encoding and decoding round-trip. +- The state codec rejects malformed values and round-trips valid state. +- `durableState` handles every incoming input and validates every local state. +- Every outgoing intent is tested for accepted, rejected, and unexpected-failure + behavior. +- Live and frozen branches of the single mount render the expected game state, + and the frozen branch cannot dispatch. +- The full project test suite passes through `./ct.sh`. + +For detailed handler and validator examples, see +[`HANDLER_GUIDE.md` — Worked Examples](HANDLER_GUIDE.md#worked-examples-reference-games). diff --git a/HANDLER_GUIDE.md b/HANDLER_GUIDE.md index 68557e748..86d15f45f 100644 --- a/HANDLER_GUIDE.md +++ b/HANDLER_GUIDE.md @@ -5,10 +5,11 @@ used by the game framework. It covers how game logic is structured, how handlers produce moves, how validators enforce rules, and how the two systems connect through the referee puzzle. -For the broader architecture (state channels, potato protocol, dispute -resolution), see `OVERVIEW.md`. For the raw calling conventions, see -`clsp/handler_api.md`. For DoS considerations (move size bounds, validation -program cost, argument checking), see `CLVM_DOS.md`. +For adding a game (package layout, registry, host APIs), see +`GAME_WRITING_GUIDE.md`. For the broader architecture (state channels, potato +protocol, dispute resolution), see `OVERVIEW.md`. For the raw calling +conventions, see `clsp/handler_api.md`. For DoS considerations (move size +bounds, validation program cost, argument checking), see `CLVM_DOS.md`. ## Table of Contents @@ -39,8 +40,9 @@ Games are driven by two cooperating systems: - **Validators** enforce the rules of each move. They are chialisp programs, one per protocol step (e.g. `a.clsp` through `e.clsp` for calpoker). They - run both off-chain (for move verification during normal play) and on-chain - (inside the referee puzzle, for slash enforcement during disputes). + run both off-chain (to check a move before sending it) and on-chain (inside + the referee puzzle, for slash enforcement during disputes). Package layout + and registration are in `GAME_WRITING_GUIDE.md`. Handlers and validators are complementary: handlers decide *what* to play, validators prove *that it was legal*. A handler that produces an illegal @@ -238,7 +240,8 @@ The proposal API takes one atomic group request: ``` `parameters` is the game-specific CLVM object and `timeout` is shared by every -game produced for the group. Both peers look up and run the same registered, +game produced for the group. Each game package's `factoryParameters` codec is +the parser for that object (see `clsp/handler_api.md`). Both peers look up and run the same registered, deterministic factory using those parameters. The factory returns a non-empty ordered list of canonical 12-field game records: @@ -776,8 +779,8 @@ and nil for `incoming_validator_hash`, signaling the game is over. ### Key Code -- Handlers: `clsp/games/calpoker/calpoker_generate.clinc` -- Validators: `clsp/games/calpoker/onchain/a.clsp` through `e.clsp` +- Handlers: `games/calpoker/clsp/calpoker_generate.clinc` +- Validators: `games/calpoker/clsp/onchain/a.clsp` through `e.clsp` - Rust-side handler invocation: `src/channel_state/game_handler.rs` - Rust-side referee state machine: `src/referee/my_turn.rs`, `src/referee/their_turn.rs` @@ -799,7 +802,6 @@ changing the authoritative move flow. **Key code:** -- Handlers: `clsp/games/spacepoker/spacepoker_generate.clinc` -- Validators: `clsp/games/spacepoker/onchain/*.clsp` -- Rust tests: `src/test_support/spacepoker.rs`, `src/tests/spacepoker_handlers.rs`, - `src/tests/spacepoker_validation.rs` +- Handlers: `games/spacepoker/clsp/spacepoker_generate.clinc` +- Validators: `games/spacepoker/clsp/onchain/*.clsp` +- Rust tests: `games/spacepoker/rust/tests/` diff --git a/OVERVIEW.md b/OVERVIEW.md index d2be5b812..4948b5172 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -497,226 +497,31 @@ puzzle hash and combined amount. `src/channel_state/mod.rs` (`get_initial_signatures`, `verify_and_store_initial_peer_signatures`) ---- --- ## Reference Games -The repository includes three reference games. **Calpoker** was implemented first -and is the simpler example: a five-step commit-reveal poker variant with one -main hand-evaluation payoff and one optional advisory pre-reveal. **Space Poker** -illustrates a more involved multi-round poker flow with repeated betting/open -states and heavier use of advisory message parsers. **Krunk** is a Wordle-style -word-guessing game that demonstrates BLS-signed dictionary enforcement and -on-chain slashing for out-of-dictionary plays. Together they show different ways -to structure validators and off-chain handlers on the same channel/referee -foundation. - -The Rust game collection also registers `debug` for simulator tests only. It is -not a user-facing reference game. - -### Calpoker - -Calpoker is a poker variant used as the simplest reference game. Two players are -dealt cards from a shared random deck and select hands through a commit-reveal -protocol that prevents either player from cheating. +The repository includes three production reference games: -### Commit-Reveal Protocol +- **Calpoker** — simplest: a commit-reveal poker variant. +- **Space Poker** — Texas Hold'em-style with messages and a terminal. +- **Krunk** — Wordle-style atomic pair; illegal input surfaces as `MoveRejected`. -The protocol ensures **fair randomness** — neither player can bias the card deal: - -``` -Step a: Alice → commit(preimage) Alice commits to her randomness -Step b: Bob → bob_seed Bob reveals his randomness -Step c: Alice → preimage + commit(salt‖discards) Alice reveals hers; cards are derived -Step d: Bob → bob_discards Bob discards 4 cards -Step e: Alice → salt‖discards‖selects Alice reveals her discards and selects -``` +Each game lives in one top-level package under `games//`, registered only +in [`games/registry.json`](games/registry.json) (`production` vs `test`). Package +keys are build/bootstrap identifiers. The protocol identity is the first +generated member's initial validation puzzle hash +(`initial_validation_program_hash`) — never the factory's hash or the +human-readable key. Registration discovers it by running the factory with +representative valid parameters. Adding a game means creating that conventional +package and appending the key to the registry; Chialisp compile, Rust/WASM +wiring, frontend imports, and the full-suite test aggregator are generated from +that file. See [`GAME_WRITING_GUIDE.md`](GAME_WRITING_GUIDE.md). Handler and +validator walkthroughs for the reference games are in +[`HANDLER_GUIDE.md`](HANDLER_GUIDE.md#worked-examples-reference-games). -**Card derivation:** `cards = make_cards(sha256(preimage ‖ bob_seed ‖ amount))`. -Since Alice committed to her preimage before seeing Bob's seed, and Bob sent his -seed before seeing Alice's preimage, neither can influence the randomness. - -**Card representation:** Integers 0–51 (`rank * 4 + suit`), called "mod-52" -format. - -**Discard commitment:** Alice commits to her discards (with a salt) before seeing -Bob's discards. This prevents Alice from choosing discards strategically based on -what Bob discards. - -**Hand evaluation:** After both players discard and select, final hands are -evaluated using `handcalc` (a chialisp hand evaluator). The final move sets -`mover_share` to reflect the outcome — the losing player (who must respond -next) receives `mover_share` on timeout, which is the smaller portion. - -### On-Chain Steps (a through e) - -Each step is a chialisp **validation program** that enforces the rules of that -step of the commit-reveal protocol: - - -| Step | Mover | Move | State After | Validates | -| ----- | --------------- | --------------------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------- | -| **a** | Alice (commits) | `sha256(preimage)` (32 bytes) | `alice_commit` | Move is exactly 32 bytes | -| **b** | Bob (seeds) | `bob_seed` (16 bytes) | `(alice_commit, bob_seed)` | Move is exactly 16 bytes | -| **c** | Alice (reveals) | `preimage ‖ sha256(salt‖discards)` (48 bytes) | `(new_commit, cards)` | `sha256(preimage) == alice_commit`; derives cards | -| **d** | Bob (discards) | `bob_discards` (1 byte) | `(bob_discards, alice_cards, bob_cards, alice_commit)` | Valid discard bitmask (popcount = 4) | -| **e** | Alice (final) | `salt‖discards‖selects` (18 bytes) | Game over | `sha256(salt‖discards) == alice_commit`; valid popcounts; hand eval; correct split | - - -At step **e**, Bob can submit his card selections as **evidence** for a slash -if Alice misclaims the split. - -### Advisory Messages (Symmetric UX) - -The commit-reveal protocol is inherently sequential — Alice and Bob take strict -turns. Without help, Bob would see nothing while Alice deliberates her move. -The game handler framework provides an **advisory message** mechanism that -lets the player who just processed a move immediately send derived information -back to the opponent, outside the logical flow of the game. - -When Alice processes Bob's step **b** (his seed), her `their_turn_handler` -derives the card deal and produces an optional `message_data` blob. This is -sent back to Bob immediately as a `PeerMessage::Message`. Bob's -`message_parser` (a CLVM program returned by his earlier `my_turn_handler`) -decodes the blob into a `ReadableMove` that the UI can display. Bob sees his -cards and can start contemplating discards while Alice is still thinking. - -The message is purely advisory: it carries no authority, doesn't change game -state, and cannot be used for cheating — the recipient will independently -derive the same information once the real move arrives. Because it is -advisory, there is no reason to bundle it with an authoritative potato pass. -And because the information it contains will be derivable by the recipient -anyway, sending it early does no strategic damage to the sender — it simply -lets the opponent start thinking sooner, making the UX feel simultaneous -even though the underlying protocol is turn-based. - -The same mechanism is available to any game, not just Calpoker. In the current -reference games, Calpoker uses it at one specific point where Alice can -pre-reveal information Bob will derive from the next formal move anyway. Space -Poker uses the same optional channel for deal/open pre-reveals that make newly -derivable card information visible earlier. In Space Poker this happens at the -beginning of each street: there is no reason to fold before at least checking, -so the player preemptively sends the reveal that will show the next street's -cards. The `my_turn_handler` returns a `message_parser` (or omits it / returns -nil if the game doesn't use advisory messages), and the `their_turn_handler` -returns `message_data` as an optional fourth element of its result. - -### Space Poker - -Space Poker is a Texas Hold'em-style reference game. It exercises a different -part of the handler API than Calpoker: multi-round state and betting/open -actions. It is registered alongside Calpoker in the Rust game collection and has -dedicated handler and validation tests. - -**Key code:** - -- `src/channel_state/game_handler.rs` — `MyTurnResult::message_parser`, -`TheirTurnResult` (message field), `MessageHandler` -- `src/session_phases/mod.rs` — sends `PeerMessage::Message` on receive; -dispatches incoming messages via `received_message` -- `clsp/games/calpoker/onchain/a.clsp` through `e.clsp` -- `clsp/games/calpoker/calpoker_generate.clinc` — off-chain handlers -- `src/test_support/calpoker_sim.rs` — Rust-side calpoker registration/helpers -- `clsp/games/spacepoker/onchain/*.clsp` -- `clsp/games/spacepoker/spacepoker_generate.clinc` — Space Poker handlers -- `src/test_support/spacepoker_sim.rs` — Rust-side Space Poker helpers - -### Krunk - -Krunk is a Wordle-style word-guessing game. Alice picks a secret 5-letter word, -commits to it (salted hash), and Bob has up to 5 guesses. After each wrong -guess Alice gives a Wordle-style clue (correct/present/absent per letter). -Bob either guesses correctly (winning a payout that decreases with each guess) -or exhausts all 5 guesses (Alice keeps everything). - -Each Krunk hand is an atomic pair of games with the same stake. One deterministic -Krunk factory invocation returns both games in a fixed order. In each individual -game, the word-picker funds the entire pot and the guesser funds nothing; -because each player is the picker once, both players put up one stake overall. -Stakes must be positive multiples of 100 mojos. - -Payouts are expressed as multiples of `base_unit = game_pot / 100`: - -| Guess # | Payout (× base_unit) | -|---------|---------------------| -| 1 | 100 | -| 2 | 100 | -| 3 | 20 | -| 4 | 5 | -| 5 | 1 | - -#### Dictionary enforcement - -Both players must play words from a fixed dictionary (`krunkwords.txt`, 5089 -five-letter words). The dictionary is enforced via **BLS signatures over gap -ranges**: the sorted dictionary has gaps between consecutive words (byte ranges -where no valid word exists). Each gap is signed with a BLS key, and the -signatures are arranged in a binary tree alongside the words. When Bob guesses a -word not in the dictionary, Alice can produce a signed gap range proving the word -falls between two adjacent dictionary entries — an `AGG_SIG_UNSAFE` condition -the blockchain can verify. - -#### Pre-signed dictionary tree - -The dictionary tree and its signatures are generated once at build time by -`cargo run --bin gen-krunk-dict`. This binary: - -1. Generates an ephemeral BLS keypair (never written to disk) -2. Signs every gap range in the sorted dictionary -3. Writes `clsp/games/krunk/krunk_signed_dict_tree.dat` — a single binary file - containing the 48-byte BLS public key followed by the CLVM-serialized signed - dictionary tree. At runtime the Rust/WASM loader splits the file, and both - values are curried into the handler programs. - -**The generated `.dat` file is checked in.** It only needs regeneration if the -dictionary changes. Regenerating requires rebuilding chialisp afterward -(`./cb.sh`). - -The `.dat` file uses a `.dat` extension (not `.hex`) because `tools/build-chialisp.sh` -deletes all `*.hex` files under `clsp/` before rebuilding to ensure a clean output -tree. - -#### Atomic factory proposals - -A proposal is one group request containing `game_type`, game-specific -`parameters`, and a timeout shared by every resulting game. Both peers run the -same registered deterministic factory. Calpoker and Space Poker factories each -return one game; Krunk returns two simultaneous games — one where each player -is Alice (word-picker) and one where each is Bob (guesser). - -Each factory result is an ordered list of canonical 12-field records containing -sender/receiver contributions, amount, `sender_goes_first`, initial move/state/ -validator commitments, fixed my-turn and their-turn handlers, and the validator -program. The higher layer selects the local initial handler and swaps the -sender/receiver contribution orientation for the receiving peer. - -One `ProposeGroup` wire action carries the whole derived group. Acceptance -preflights aggregate balances for the complete group; accept and cancel apply -to every member or none. The receiver -gets one `ProposalMade` notification with ordered IDs. See -[Grouped Proposals](GAME_LIFECYCLE.md#grouped-atomic-proposals) for the -general mechanism. - -#### On-chain validators - -| Validator | Move | Validates | -|-----------|------|-----------| -| `commit.clsp` | `sha256(salt ‖ word)` (32 bytes) | Move is 32 bytes; initializes state with `(dict_pubkey, base_unit)` | -| `guess.clsp` | 5-letter guess | Word is 5 bytes; evidence = signed gap range for out-of-dictionary slash | -| `clue.clsp` | clue byte (1 byte) or `salt ‖ word` (21 bytes, reveal) | Clue correctness; reveal verifies `sha256(salt ‖ word) == commit`; wrong-clue slash via evidence index | - -**Key code:** - -- `clsp/games/krunk/krunk_generate.clinc` — off-chain handlers (Alice/Bob) -- `clsp/games/krunk/onchain/{commit,guess,clue}.clsp` — on-chain validators -- `clsp/games/krunk/krunk_helpers.clinc` — clue encoding, payout tables -- `clsp/games/krunk/krunk_signed_dict_tree.dat` — generated: 48-byte pubkey + signed tree (binary) -- `src/games/krunk_dict_tree.rs` — tree construction and gap signing logic -- `src/bin/gen_krunk_dict.rs` — dictionary tree generator binary -- `src/tests/krunk_handlers.rs` — handler tests -- `src/tests/krunk_validation.rs` — on-chain validation tests -- `src/test_support/krunk_sim.rs` — Krunk test registration and helpers +The Rust game collection also registers `debug` (test list) for simulator tests +only. It is not a user-facing reference game. --- @@ -727,11 +532,19 @@ All phases implement the `PeerLifecyclePhase` trait (defined in `src/game_sessio which provides a uniform interface for receiving messages, responding to coin-watching events, and performing game actions. The `GameSession` holds a single `Box` and routes all events through it. +The trait has no behavioral defaults: every concrete phase explicitly defines +every operation as valid behavior, an intentional no-op, or a phase-specific +error. This keeps `GameSession` phase-agnostic and makes additions to the +operation surface a compile-time checklist for every phase. Phase-specific +operations such as handshake start and timeout status updates also use this +interface rather than runtime type downcasts. When a phase is complete, it produces the next phase via `take_next_phase()`. The session detects this in `detect_phase_transition` -and swaps in the new phase. This creates a linear progression through -the channel lifecycle: +and swaps in the new phase. Each concrete phase constructs its own successor +because it owns the state-transfer knowledge; the successors deliberately have +different constructor shapes. This creates a linear progression through the +channel lifecycle: ``` HandshakeInitiator ─┐ @@ -791,7 +604,8 @@ Protocol lifetime ends only after queued terminal reductions and the durable terminal snapshot are flushed, at which point the real controller and transport attachments are destroyed. Visual lifetime can continue: the same React hand component and `handKey` remain mounted, but receive the finalized model through -an inert frozen bridge. Cold restoration is separate again: +the `frozen: true` branch of the same mount contract, which structurally has no +intent port. Cold restoration is separate again: `FinishedSessionGameView` remounts a validated persisted Cal Poker, Space Poker, or Krunk hand only when no live tree survived (for example, after reload). @@ -838,14 +652,15 @@ Shared utilities used by multiple handlers (e.g. `build_channel_to_unroll_bundle | --------------------------------------------- | --------------------------------------------------------- | | `clsp/unroll/unroll_puzzle.clsp` | Unroll coin: timeout vs challenge with sequence numbers | | `clsp/referee/onchain/referee.clsp` | Game coin: move / timeout / slash enforcement | -| `clsp/games/calpoker/onchain/{a,b,c,d,e}.clsp` | Calpoker validation programs (one per protocol step) | -| `clsp/games/calpoker/calpoker_generate.clinc` | Off-chain calpoker handlers (Alice & Bob sides) | -| `clsp/games/spacepoker/onchain/*.clsp` | Space Poker validation programs | -| `clsp/games/spacepoker/spacepoker_generate.clinc` | Off-chain Space Poker handlers | -| `clsp/games/krunk/onchain/{commit,guess,clue}.clsp` | Krunk validation programs | -| `clsp/games/krunk/krunk_generate.clinc` | Off-chain Krunk handlers (Alice & Bob sides) | -| `clsp/games/krunk/krunk_signed_dict_tree.dat`| Generated: pubkey + signed dict tree, binary (see [Krunk](#krunk)) | -| `clsp/test/debug_game.clsp` | Debug game: validator, my-turn, their-turn, and factory | +| `clsp/games/game_codes.clinc` | Shared game error codes | +| `games/calpoker/clsp/onchain/{a,b,c,d,e}.clsp` | Calpoker validation programs (one per protocol step) | +| `games/calpoker/clsp/calpoker_generate.clinc` | Off-chain calpoker handlers (Alice & Bob sides) | +| `games/spacepoker/clsp/onchain/*.clsp` | Space Poker validation programs | +| `games/spacepoker/clsp/spacepoker_generate.clinc` | Off-chain Space Poker handlers | +| `games/krunk/clsp/onchain/{commit,guess,clue}.clsp` | Krunk validation programs | +| `games/krunk/clsp/krunk_generate.clinc` | Off-chain Krunk handlers (Alice & Bob sides) | +| `games/krunk/clsp/krunk_signed_dict_tree.dat`| Generated: pubkey + signed dict tree, binary | +| `games/debug/clsp/factory.clsp` | Debug game: validator, my-turn, their-turn, and factory | | `clsp/handler_api.md` | Handler calling conventions (see also `HANDLER_GUIDE.md`) | @@ -854,10 +669,10 @@ Shared utilities used by multiple handlers (e.g. `build_channel_to_unroll_bundle | File | Purpose | | ------------------------------------------- | -------------------------------------------------------- | -| `src/test_support/calpoker_sim.rs` | Calpoker test registration and helpers | -| `src/test_support/spacepoker_sim.rs` | Space Poker test registration and helpers | -| `src/test_support/krunk_sim.rs` | Krunk test registration and helpers | -| `src/test_support/debug_game.rs` | Debug game: minimal game with controllable `mover_share` | +| `games/calpoker/rust/tests/sim.rs` | Calpoker test registration and helpers | +| `games/spacepoker/rust/tests/sim.rs` | Space Poker test registration and helpers | +| `games/krunk/rust/tests/sim.rs` | Krunk test registration and helpers | +| `games/debug/rust/mod.rs` | Debug game: minimal game with controllable `mover_share` | | `src/simulator/tests/session_phases_sim.rs` | Integration tests including notification suite | | `src/test_support/peer/peer_harness.rs` | Test peer helper | | `src/test_support/sim_script.rs` | `SimScriptAction` enum and simulation loop driver | @@ -887,7 +702,7 @@ Shared utilities used by multiple handlers (e.g. `build_channel_to_unroll_bundle | `ValidationInfo` | `channel_state/types/validation_info.rs` | Game validation program + state | | `CachedRedoActions` | `channel_state/types/potato.rs` | Enum for `cached_redo_actions` entries: `CachedSendMove`, `CachedAcceptSettlement`, `ProposalAccepted` | | `BatchAction` | `session_phases/types.rs` | Peer-level batch action variants: group-level `ProposeGroup`, per-ID `AcceptProposal` / `CancelProposal` expanded atomically by the higher layer, `Move`, `AcceptSettlement` | -| `GameAction` | `session_phases/types.rs` | Actions: `Move`, `AcceptSettlement`, `SendPotato`, `QueuedProposalGroup`, `CleanShutdown`, `Cheat` | +| `GameAction` | `session_phases/types.rs` | Actions: `Move`, `AcceptSettlement`, `CleanShutdown`, `QueuedProposalGroup`, `QueuedAcceptProposal`, `QueuedCancelProposal`, `QueuedCancelProposalSilently`, `Cheat` | | `GameSessionState` | `game_session.rs` | Per-session mutable state: queues, flags, `peer_disconnected` | | `OnChainGameState` | `channel_state/types/on_chain_game_state.rs` | Per-game-coin tracking: `our_turn`, `puzzle_hash`, `timeout_claim_armed`, `timeout_claim`, `pending_slash_amount`, `game_timeout` | | `SettlementOutcome` | `session_phases/effects.rs` | Settlement glossary ids (snake_case wire): off-chain `accept_settlement` plus on-chain outcomes #1–#11; see [Settlement glossary](NAMING_AUDIT.md#settlement-glossary-ux) | @@ -906,6 +721,7 @@ Shared utilities used by multiple handlers (e.g. `build_channel_to_unroll_bundle | Document | Covers | | --- | --- | +| [`GAME_WRITING_GUIDE.md`](GAME_WRITING_GUIDE.md) | How to write a game: package layout, registry hook, host and CLVM APIs | | [`GAME_LIFECYCLE.md`](GAME_LIFECYCLE.md) | Game proposals, off-chain game flow, AcceptSettlement lifecycle | | [`ON_CHAIN.md`](ON_CHAIN.md) | Dispute resolution, clean shutdown, preemption, stale unrolls, the referee, on-chain game state tracking | | [`UX_NOTIFICATIONS.md`](UX_NOTIFICATIONS.md) | Notification types, lifecycle invariants, WASM event FIFO | diff --git a/README.md b/README.md index 21b130c15..f17755811 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ This project enables two-player games for real money over Chia state channels, w messages (the "potato protocol"). The blockchain is only touched for opening, closing, or resolving disputes. -The reference games are **California Poker** — a poker variant using commit-reveal randomness, and **Space Poker**, a Texas Hold'em variant. +The reference games are **California Poker** (commit-reveal), **Space Poker** +(Texas Hold'em-style), and **Krunk** (Wordle-style paired games). See +**[GAME_WRITING_GUIDE.md](GAME_WRITING_GUIDE.md)** to add a game. For production builds, tarballs, and step-by-step build instructions, see **[DEVELOPMENT.md](DEVELOPMENT.md)**. @@ -12,9 +14,11 @@ For production builds, tarballs, and step-by-step build instructions, see ## Documentation -- **[OVERVIEW.md](OVERVIEW.md)** — How state channels, the referee, the - potato protocol, and Calpoker work. Links to detailed docs. -- **[DEVELOPMENT.md](DEVELOPMENT.md)** — Build, debug, and run the player app and hub +- **[OVERVIEW.md](OVERVIEW.md)** — How state channels, the referee, and the + potato protocol work. Links to detailed docs. +- **[GAME_WRITING_GUIDE.md](GAME_WRITING_GUIDE.md)** — How to write a game: package + layout, registry, host and CLVM APIs. +- **[DEVELOPMENT.md](DEVELOPMENT.md)** — Build, debug, and run the player app and hub locally or in production. - **[FRONTEND_ARCHITECTURE.md](FRONTEND_ARCHITECTURE.md)** — Player app and hub: React components, WASM bridge, WebSocket relay protocol. @@ -24,21 +28,21 @@ For production builds, tarballs, and step-by-step build instructions, see ``` src/ - channel_state/ — State channel management and the potato protocol + channel_state/ — State channel management and the potato protocol referee/ — Referee coin logic (on-chain move validation, slashing) session_phases/ — High-level game orchestration and on-chain actions - games/ — Game registration (calpoker, spacepoker, test-only debug game) peer_container.rs — Peer-to-peer game cradle (synchronous wrapper) simulator/ — Chia blockchain simulator and integration tests test_support/ — Shared test utilities common/ — Shared types, CLVM utilities, standard coin logic shutdown.rs — Clean shutdown conditions +games/ — Game packages (`/{clsp,rust,ui}`) and `host/` + registry.json — Only catalog (`production` vs `test`) clsp/ - games/calpoker/ — Calpoker chialisp (handlers, validators, handcalc) - games/spacepoker/ — Space Poker chialisp (handlers, validators, hand eval) referee/onchain/ — Referee puzzle (on-chain arbitration) unroll/ — Unroll puzzle (state channel dispute resolution) + handler_api.md — CLVM handler calling conventions test/ — Chialisp test programs wasm/ — WebAssembly bindings for browser use diff --git a/UX_NOTIFICATIONS.md b/UX_NOTIFICATIONS.md index 7d99749c2..410f7dd1e 100644 --- a/UX_NOTIFICATIONS.md +++ b/UX_NOTIFICATIONS.md @@ -13,9 +13,10 @@ seed. Rust notifications are protocol facts. JavaScript renders and persists their browser envelope, but does not infer settlement, channel lifecycle, or -protocol validity from display data. A UI action is an intent sent to Rust; its -result becomes authoritative only when Rust emits the corresponding -notification. The sole JS exception is explicit client capability policy, such +protocol validity from display data. A UI action is an intent sent to Rust. +`LocalActionApplied` is the host-only fact that an immediate or queued local +action was actually applied; merely accepting an API call into Rust's queue is +not enough. The sole JS exception is explicit client capability policy, such as declining a second concurrent proposal group while still supporting each independently progressing game within an accepted group. @@ -40,7 +41,7 @@ like "OpponentMoved" for readability. The canonical wire model in Rust is - dedicated variants: `ProposalMade`, `ProposalAccepted`, `ProposalCancelled`, `InsufficientBalance`, `MoveRejected`, `ActionFailed`, - `ChannelStatus` + host-only `LocalActionApplied`, and `ChannelStatus` - gameplay lifecycle (non-terminal): `GameNotification::GameStatus { status: GameStatusKind, ... }` - **settlements (terminal):** `GameNotification::GameSettled { id, outcome, @@ -383,6 +384,7 @@ These fire during active gameplay (after a game proposal has been accepted). | OpponentPlayedIllegalMove | `GameStatus { status: IllegalMoveDetected, ... }` | Opponent's on-chain move detected as illegal | Emitted before slash resolution | | GameMessage | `GameStatus { status: MyTurn/TheirTurn, other_params: { readable } }` | Informational game message | Decoded advisory/readable message payload | | MoveRejected | `MoveRejected { id, tag, message }` | A local my-turn handler rejects user input | Recoverable game-scoped rejection; no peer batch is sent for the rejected move | +| LocalActionApplied | `LocalActionApplied { id, action }` | A local move, settlement acceptance, or diagnostic cheat is actually applied | Host-only candidate lifecycle signal. The host promotes the separately staged candidate exactly once; game packages never receive this notification. | | GameOnChain | `GameStatus { status: OnChainMyTurn / OnChainTheirTurn / Replaying, coin_id }` | Game transitions on-chain | On-chain phase begins for this game. `Replaying` means a cached off-chain send-move exists for this game id and will be spent as an on-chain redo (same criterion as `take_cached_move_for_game`). | | PlayingMove | `GameStatus { status: PlayingMove, coin_id }` | The host accepted an on-chain move for publication and we are waiting for confirmation | Transient pending-move status. In the browser, the preceding spend has entered the serialized wallet RPC submission lane; this does not claim that the asynchronous RPC succeeded, reached a full-node mempool, or confirmed on chain. In the simulator, the synchronous host boundary has already submitted it to the simulator mempool before delivering this notification. Followed by `OnChainTheirTurn { moved_by_us: true }` when the spend lands. Distinct from `Replaying`, which is a cached off-chain redo action being replayed on-chain. | | WeMoved | `GameStatus { status: OnChainTheirTurn, other_params: { moved_by_us: true }, coin_id }` | Our on-chain move confirms | New game coin is tracked in `coin_id` | @@ -406,7 +408,7 @@ user is notified. | `CancelReason` | Emitted when | Frontend behavior | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SupersededByIncoming` | A peer proposal arrived in a batch while our own proposal was queued locally. WASM removes our queued proposal because the state it was built against is now stale. | **Local/silent.** Terms stashed in `pendingRetryTermsRef` for automatic re-submission (see [Proposal Collision Handling](GAME_LIFECYCLE.md#proposal-collision-handling)). | -| `PeerProposalPending` | JS called `propose_game` while an unresolved peer proposal already exists in `proposed_games`. WASM rejects immediately to avoid silently cancelling the peer's proposal as a side effect. | **Local/silent.** Same retry stash as `SupersededByIncoming`. | +| `PeerProposalPending` | JS called `propose_games` while an unresolved peer proposal already exists in `proposed_games`. WASM rejects immediately to avoid silently cancelling the peer's proposal as a side effect. | **Local/silent.** Same retry stash as `SupersededByIncoming`. | | `GameActive` | Reserved for future use. The JS-side guard prevents this from occurring in practice. | **Local/silent.** Clears retry state. | | `CancelledByPeer` | The peer sent `BatchAction::CancelProposal` for our proposal. This usually means the peer rejected it, but the same protocol message is also used as the peer-side follow-up for failed accept attempts such as insufficient balance (see [Race Conditions in Proposal Lifecycle](GAME_LIFECYCLE.md#race-conditions-in-proposal-lifecycle)). | **User-facing notice:** the proposal did not proceed on the peer side. | | `CancelledByUs` | We explicitly cancelled the peer's proposal (via `cancel_proposal`). | **Silent.** We initiated the cancellation; nothing to tell the user. | @@ -441,9 +443,9 @@ GameSettled { id, outcome: SettlementOutcome, our_share, coin_id? } `opponent_timed_out`, `forfeited_skipped_reveal`, …). `our_share` is always present, including `0`. -**Dual delivery:** the same payload drives (1) the session banner / dashboard -label and (2) the active reference-game UI via `GameplayEvent.Settled`. -Neither sink may invent a parallel event shape or skip "boring" outcomes. +The host normalizes the payload once into the terminal instance and +`hand-ended` model input. Session banners and game mounts both render that +machine-owned result; there is no second event delivery. Display labels come from `SETTLEMENT_OUTCOME_LABELS` in `front-end/src/lib/settlement.ts`. @@ -523,7 +525,7 @@ open item is explicitly resolved. ### Local actions are advisory -Calling `propose_game`, `accept_proposal`, or `cancel_proposal` queues an +Calling `propose_games`, `accept_proposal`, or `cancel_proposal` queues an intent. The potato protocol resolves it when the potato is held and the queue is drained. The notification stream — not the API call — is the source of truth. One proposal call represents one factory-derived group and the receiver @@ -535,7 +537,7 @@ is drained). ### Rule A — Proposal lifecycle -Every group-start event — a `propose_game` call (proposer side) or the single +Every group-start event — a `propose_games` call (proposer side) or the single `ProposalMade` notification (receiver side) — covers the ordered IDs returned by the deterministic factory. Each member ID yields exactly one `ProposalAccepted` or `ProposalCancelled` on that player's side, but group @@ -690,29 +692,26 @@ These are not lifecycle invariants but important rules enforced in the code: --- -## GameplayEvent Mapping +## Game Model Input Mapping -The `useGameSession` hook translates raw `WasmNotification` events into -game-agnostic `GameplayEvent` variants before forwarding them to -game-specific hooks (`useCalpokerHand`, `useSpacepokerHand`, `useKrunkHand`). -Game hooks never see raw notifications; they receive one of: +`sessionMachineNotifications.ts` normalizes raw notifications directly into +the machine-owned hand model. Game hooks never see raw notifications or an +observable. The package input list is: -| Variant | Shape | When | -| ------------------ | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `OpponentMoved` | `{ readable, gameId?, moverShare: string }` | Remapped from `GameStatus` with `other_params.readable` and `other_params.mover_share`. `moverShare` is our share after the opponent's move (including on timeout from that move). | -| `GameMessage` | `{ readable, gameId? }` | Remapped from `GameStatus` with readable but no `mover_share` (advisory / out-of-band message). | -| `ProposalAccepted` | `{ id }` | A new game is starting | -| `Settled` | `{ gameId, outcome, ourShare }` | From `GameSettled`; same payload drives session banner labels via `terminalInfoFromGameSettled` | -| `MoveRejected` | `{ gameId: string, tag: string, message: string }` | Recoverable local handler rejection routed only to the matching game hook | -| `GameError` | `{ gameId, reason }` | `EndedCancelled`, `EndedError`, `InsufficientBalance`, or unknown settlement outcome | +- `hand-started` +- `opponent-moved` +- `game-message` +- `move-rejected` +- `hand-ended` + +`ActionFailed`, JavaScript command exceptions, proposal/session lifecycle, and +infrastructure failures remain host-owned and use the shared notification +queues. Settlement label helpers live in `front-end/src/lib/settlement.ts` (`settlementLabel`, `isForfeitOutcome`, game-specific copy helpers). -Non-terminal move/status notifications are remapped by -`gameplayEventsForGameStatus` into the `OpponentMoved` / `GameMessage` shapes -above (including `moverShare` on `OpponentMoved`). - -**Key code:** `front-end/src/hooks/useGameSession.ts` (`terminalInfoFromGameSettled`, -`settledEventForInfo`, `gameplayEventsForGameStatus`), +**Key code:** `front-end/src/lib/session/sessionMachineNotifications.ts`, +`front-end/src/lib/session/sessionMachineGame.ts`, +`front-end/src/lib/session/gameSessionEvents.ts`, and `front-end/src/lib/settlement.ts` diff --git a/build.rs b/build.rs index 84fa99d22..61b5a53d6 100644 --- a/build.rs +++ b/build.rs @@ -1,8 +1,9 @@ use std::collections::HashMap; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use clvmr::allocator::Allocator; +use serde_json::Value as JsonValue; use toml::{Table, Value}; use chialisp::classic::clvm_tools::clvmc::CompileError; @@ -11,6 +12,12 @@ use chialisp::classic::platform::argparse::ArgumentValue; use chialisp::compiler::comptypes::CompileErr; use chialisp::compiler::srcloc::Srcloc; +#[derive(Clone, Debug)] +struct GameRegistry { + production: Vec, + test: Vec, +} + fn do_compile(title: &str, filename: &str) -> Result<(), CompileError> { let mut allocator = Allocator::new(); let mut arguments: HashMap = HashMap::new(); @@ -47,7 +54,100 @@ fn do_compile(title: &str, filename: &str) -> Result<(), CompileError> { Ok(()) } -fn compile_chialisp() -> Result<(), CompileError> { +fn string_list(value: Option<&JsonValue>, field: &str) -> Vec { + match value { + Some(JsonValue::Array(items)) => items + .iter() + .map(|item| { + item.as_str() + .unwrap_or_else(|| { + panic!("games/registry.json {field} entries must be strings") + }) + .to_string() + }) + .collect(), + _ => panic!("games/registry.json missing {field} array"), + } +} + +fn load_registry() -> GameRegistry { + let text = fs::read_to_string("games/registry.json") + .unwrap_or_else(|e| panic!("failed to read games/registry.json: {e}")); + let json: JsonValue = + serde_json::from_str(&text).unwrap_or_else(|e| panic!("invalid games/registry.json: {e}")); + GameRegistry { + production: string_list(json.get("production"), "production"), + test: string_list(json.get("test"), "test"), + } +} + +fn is_valid_package_key(key: &str) -> bool { + !key.is_empty() + && key + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') +} + +fn validate_package(key: &str, production: bool) { + if !is_valid_package_key(key) { + panic!("invalid game package key {key:?}"); + } + let root = PathBuf::from("games").join(key); + let rust_mod = root.join("rust/mod.rs"); + let rust_tests = root.join("rust/tests/mod.rs"); + let factory = root.join("clsp/factory.clsp"); + if !rust_mod.is_file() { + panic!("game package {key} missing rust/mod.rs"); + } + if !rust_tests.is_file() { + panic!("game package {key} missing rust/tests/mod.rs"); + } + if !factory.is_file() { + panic!("game package {key} missing clsp/factory.clsp"); + } + if production { + for rel in [ + "ui/handProposal.ts", + "ui/handProposalForm.tsx", + "ui/play.tsx", + ] { + if !root.join(rel).is_file() { + panic!("production game package {key} missing {rel}"); + } + } + } +} + +fn package_clsp_entrypoints(key: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + let factory = format!("games/{key}/clsp/factory.clsp"); + out.push((format!("{key}-factory"), factory)); + let onchain = PathBuf::from(format!("games/{key}/clsp/onchain")); + if onchain.is_dir() { + let mut files: Vec = fs::read_dir(&onchain) + .unwrap_or_else(|e| panic!("read {onchain:?}: {e}")) + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("clsp")) + .filter(|p| { + p.file_stem() + .and_then(|s| s.to_str()) + .is_some_and(|stem| !stem.starts_with("test_")) + }) + .collect(); + files.sort(); + for file in files { + let name = file + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("onchain"); + out.push((format!("{key}-{name}"), file.to_string_lossy().into_owned())); + } + } + out +} + +fn compile_chialisp(registry: &GameRegistry) -> Result<(), CompileError> { let srcloc = Srcloc::start("chialisp.toml"); let chialisp_toml_text = fs::read_to_string("chialisp.toml").map_err(|e| { CompileError::Modern( @@ -68,6 +168,17 @@ fn compile_chialisp() -> Result<(), CompileError> { } } + let mut seen = std::collections::BTreeSet::new(); + for key in registry.production.iter().chain(registry.test.iter()) { + if !seen.insert(key) { + panic!("duplicate game package key {key}"); + } + validate_package(key, registry.production.iter().any(|k| k == key)); + for (title, path) in package_clsp_entrypoints(key) { + do_compile(&title, &path)?; + } + } + Ok(()) } @@ -78,7 +189,7 @@ fn emit_rerun_directives(dir: &Path) { if path.is_dir() { emit_rerun_directives(&path); } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - if ext == "clsp" || ext == "clinc" { + if ext == "clsp" || ext == "clinc" || ext == "json" || ext == "rs" { println!("cargo:rerun-if-changed={}", path.display()); } } @@ -86,13 +197,89 @@ fn emit_rerun_directives(dir: &Path) { } } +fn generate_package_modules(registry: &GameRegistry, out_dir: &Path) { + let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let mut packages = String::new(); + for key in registry.production.iter().chain(registry.test.iter()) { + let path = manifest_dir.join("games").join(key).join("rust/mod.rs"); + packages.push_str(&format!( + "#[path = \"{}\"]\npub mod {key};\n", + path.display().to_string().replace('\\', "\\\\") + )); + } + fs::write(out_dir.join("game_packages.rs"), packages).unwrap(); + + let mut register = + String::from("pub fn production_package_keys() -> &'static [&'static str] {\n &["); + for key in ®istry.production { + register.push_str(&format!("\"{key}\", ")); + } + register.push_str("]\n}\n\npub fn test_package_keys() -> &'static [&'static str] {\n &["); + for key in ®istry.test { + register.push_str(&format!("\"{key}\", ")); + } + register.push_str( + r#"] +} + +pub fn register_one_package( + allocator: &mut crate::common::types::AllocEncoder, + key: &str, + factories: &mut std::collections::BTreeMap< + crate::common::types::GameType, + crate::session_phases::types::GameFactory, + >, + package_ids: &mut Vec<(String, crate::common::types::GameType)>, +) { + match key { +"#, + ); + for key in registry.production.iter().chain(registry.test.iter()) { + register.push_str(&format!( + " \"{key}\" => {{\n let factory = crate::games::{key}::prepared_factory(allocator).unwrap_or_else(|e| panic!(\"package {key} factory: {{e:?}}\"));\n let probe = crate::games::{key}::probe_parameters(allocator).unwrap_or_else(|e| panic!(\"package {key} probe: {{e:?}}\"));\n crate::session_phases::game_collection::register_package(\n allocator,\n \"{key}\",\n factory,\n probe,\n factories,\n package_ids,\n );\n }}\n" + )); + } + register.push_str( + r#" other => panic!("unknown game package {other}"), + } +} +"#, + ); + fs::write(out_dir.join("game_register.rs"), register).unwrap(); + + let mut tests = String::from( + "pub fn game_package_test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> {\n let mut funs = Vec::new();\n", + ); + for key in registry.production.iter().chain(registry.test.iter()) { + tests.push_str(&format!( + " funs.extend(crate::games::{key}::tests::test_funs());\n" + )); + } + tests.push_str(" funs\n}\n"); + fs::write(out_dir.join("game_package_test_funs.rs"), tests).unwrap(); +} + fn main() { + let registry = load_registry(); + let mut seen = std::collections::BTreeSet::new(); + for key in registry.production.iter().chain(registry.test.iter()) { + if !seen.insert(key) { + panic!("duplicate game package key {key}"); + } + validate_package(key, registry.production.iter().any(|k| k == key)); + } + emit_rerun_directives(Path::new("clsp")); + emit_rerun_directives(Path::new("games")); println!("cargo:rerun-if-changed=chialisp.toml"); + println!("cargo:rerun-if-changed=games/registry.json"); println!("cargo:rerun-if-env-changed=CHIALISP_COMPILE"); + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + generate_package_modules(®istry, &out_dir); + if std::env::var("CHIALISP_COMPILE").is_ok() { - if let Err(e) = compile_chialisp() { + if let Err(e) = compile_chialisp(®istry) { panic!("error compiling chialisp: {e:?}"); } } diff --git a/chialisp.toml b/chialisp.toml index 35c4bfc21..3f2434e03 100644 --- a/chialisp.toml +++ b/chialisp.toml @@ -1,22 +1,5 @@ [compile] unroll-puzzle = "clsp/unroll/unroll_puzzle.clsp" -debug-game = "clsp/test/debug_game.clsp" -calpoker-generate = "clsp/games/calpoker/calpoker_include.clsp" -calpoker-validator-a = "clsp/games/calpoker/onchain/a.clsp" -calpoker-validator-b = "clsp/games/calpoker/onchain/b.clsp" -calpoker-validator-c = "clsp/games/calpoker/onchain/c.clsp" -calpoker-validator-d = "clsp/games/calpoker/onchain/d.clsp" -calpoker-validator-e = "clsp/games/calpoker/onchain/e.clsp" -spacepoker-generate = "clsp/games/spacepoker/spacepoker_include.clsp" -spacepoker-validator-commitA = "clsp/games/spacepoker/onchain/commitA.clsp" -spacepoker-validator-commitB = "clsp/games/spacepoker/onchain/commitB.clsp" -spacepoker-validator-begin-round = "clsp/games/spacepoker/onchain/begin_round.clsp" -spacepoker-validator-mid-round = "clsp/games/spacepoker/onchain/mid_round.clsp" -spacepoker-validator-end = "clsp/games/spacepoker/onchain/end.clsp" -krunk-generate = "clsp/games/krunk/krunk_include.clsp" -krunk-validator-commit = "clsp/games/krunk/onchain/commit.clsp" -krunk-validator-guess = "clsp/games/krunk/onchain/guess.clsp" -krunk-validator-clue = "clsp/games/krunk/onchain/clue.clsp" onchain-referee = "clsp/referee/onchain/referee.clsp" mock-validator = "clsp/test/mock_validator.clsp" handcalc-micro = "clsp/test/test_handcalc_micro.clsp" diff --git a/clsp/games/calpoker/calpoker_include.clsp b/clsp/games/calpoker/calpoker_include.clsp deleted file mode 100644 index 1e0b3c432..000000000 --- a/clsp/games/calpoker/calpoker_include.clsp +++ /dev/null @@ -1,5 +0,0 @@ -(include *standard-cl-23*) - -(import games.calpoker.calpoker_generate exposing calpoker_factory) - -(export calpoker_factory) diff --git a/clsp/games/calpoker/game_codes.clinc b/clsp/games/calpoker/game_codes.clinc deleted file mode 100644 index a52eeb6a1..000000000 --- a/clsp/games/calpoker/game_codes.clinc +++ /dev/null @@ -1,6 +0,0 @@ -(defconst MAKE_MOVE 0) -(defconst ACCEPT 1) -(defconst SLASH 2) -(defconst TIMEOUT 3) -(defconst SLASHED 4) -(defconst TIMEDOUT 5) diff --git a/clsp/games/krunk/krunk_include.clsp b/clsp/games/krunk/krunk_include.clsp deleted file mode 100644 index 61bcdcfc5..000000000 --- a/clsp/games/krunk/krunk_include.clsp +++ /dev/null @@ -1,5 +0,0 @@ -(include *standard-cl-23*) - -(import games.krunk.krunk_generate exposing krunk_factory) - -(export krunk_factory) diff --git a/clsp/games/spacepoker/spacepoker_include.clsp b/clsp/games/spacepoker/spacepoker_include.clsp deleted file mode 100644 index e05cc0e7e..000000000 --- a/clsp/games/spacepoker/spacepoker_include.clsp +++ /dev/null @@ -1,5 +0,0 @@ -(include *standard-cl-23*) - -(import games.spacepoker.spacepoker_generate exposing spacepoker_factory) - -(export spacepoker_factory) diff --git a/clsp/handler_api.md b/clsp/handler_api.md index 50a55a2ec..69b3ca082 100644 --- a/clsp/handler_api.md +++ b/clsp/handler_api.md @@ -24,7 +24,7 @@ my-turn followed by their-turn. Because both peers execute the identical factory output, sender/receiver and my/their are interpreted relative to the proposal sender when the records are installed. -Canonical parameters: +Canonical parameters, also exposed by each game's `factoryParameters` codec: - Calpoker: proper list `(per_player_stake sender_goes_first)`. - Space Poker: proper list diff --git a/clsp/test/test_dict_lookup.clsp b/clsp/test/test_dict_lookup.clsp index 6ce53868f..960f6bf1c 100644 --- a/clsp/test/test_dict_lookup.clsp +++ b/clsp/test/test_dict_lookup.clsp @@ -1,6 +1,6 @@ (include *standard-cl-23*) -(import games.krunk.krunk_dict_tree exposing dict_lookup) +(import games.krunk.clsp.krunk_dict_tree exposing dict_lookup) ; Args: (tree word left_sentinel right_sentinel) ; Returns dict_lookup result: () if in dict, (left_bound right_bound signature) if not. diff --git a/clsp/test/test_handcalc_micro.clsp b/clsp/test/test_handcalc_micro.clsp index 554aff26a..a76aadb1c 100644 --- a/clsp/test/test_handcalc_micro.clsp +++ b/clsp/test/test_handcalc_micro.clsp @@ -1,8 +1,8 @@ (include *standard-cl-23*) -(import games.calpoker.handcalc) -(import games.calpoker.onchain.make_cards) -(import games.calpoker.onchain.onehandcalc) -(import games.calpoker.onchain.arrange_cards) +(import games.calpoker.clsp.handcalc) +(import games.calpoker.clsp.onchain.make_cards) +(import games.calpoker.clsp.onchain.onehandcalc) +(import games.calpoker.clsp.onchain.arrange_cards) (export (kind . arguments) (if diff --git a/clsp/test/test_make_cards.clsp b/clsp/test/test_make_cards.clsp index c2b643681..832e570a7 100644 --- a/clsp/test/test_make_cards.clsp +++ b/clsp/test/test_make_cards.clsp @@ -1,5 +1,5 @@ (include *standard-cl-23*) -(import games.calpoker.onchain.make_cards exposing make_cards) +(import games.calpoker.clsp.onchain.make_cards exposing make_cards) (export (randomness) (make_cards randomness) diff --git a/clsp/test/test_mergein.clsp b/clsp/test/test_mergein.clsp index 02caf25ef..03bcdbfd4 100644 --- a/clsp/test/test_mergein.clsp +++ b/clsp/test/test_mergein.clsp @@ -1,5 +1,5 @@ (include *standard-cl-23*) -(import games.calpoker.onchain.make_cards exposing mergein) +(import games.calpoker.clsp.onchain.make_cards exposing mergein) (export (inner outer offset) (mergein inner outer offset) diff --git a/clsp/test/test_space_hand_eval.clsp b/clsp/test/test_space_hand_eval.clsp index 2d9b077c5..cbb8d9956 100644 --- a/clsp/test/test_space_hand_eval.clsp +++ b/clsp/test/test_space_hand_eval.clsp @@ -1,7 +1,7 @@ (include *standard-cl-23*) -(import games.spacepoker.onchain.space_hand_eval exposing space_hand_eval) -(import games.spacepoker.space_hand_calc exposing space_hand_calc) +(import games.spacepoker.clsp.onchain.space_hand_eval exposing space_hand_eval) +(import games.spacepoker.clsp.space_hand_calc exposing space_hand_calc) (export space_hand_eval) (export space_hand_calc) diff --git a/clsp/test/unused/test_handcalc.clsp b/clsp/test/unused/test_handcalc.clsp index 75ffefc42..d463dcbb4 100644 --- a/clsp/test/unused/test_handcalc.clsp +++ b/clsp/test/unused/test_handcalc.clsp @@ -13,7 +13,7 @@ (import std.append) (import handcalc exposing handcalc) -(import games.calpoker.onchain.onehandcalc exposing onehandcalc) +(import games.calpoker.clsp.onchain.onehandcalc exposing onehandcalc) (defun cards-by-bitmask (mask cards) (if cards diff --git a/clsp/test/unused/test_onehandcalc.clsp b/clsp/test/unused/test_onehandcalc.clsp index 22e84d5df..808499440 100644 --- a/clsp/test/unused/test_onehandcalc.clsp +++ b/clsp/test/unused/test_onehandcalc.clsp @@ -9,7 +9,7 @@ (import std.permutations) (import std.last) (import std.busy) -(import games.calpoker.onchain.onehandcalc exposing atomsort) +(import games.calpoker.clsp.onchain.onehandcalc exposing atomsort) (defun try_list (mylist newlist) (assert (deep= (print 'result' (atomsort (print 'about to sort' newlist))) mylist) 0) diff --git a/eslint.config.mjs b/eslint.config.mjs index 0389f0dfc..b0c42b23b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,7 +6,11 @@ import globals from 'globals'; import tseslint from 'typescript-eslint'; const sourceFiles = ['**/*.{js,mjs,cjs,ts,tsx}']; -const reactFiles = ['front-end/**/*.{ts,tsx}', 'hub/hub-frontend/**/*.{ts,tsx}']; +const reactFiles = [ + 'front-end/**/*.{ts,tsx}', + 'hub/hub-frontend/**/*.{ts,tsx}', + 'games/**/*.{ts,tsx}', +]; export default defineConfig([ globalIgnores([ @@ -75,7 +79,11 @@ export default defineConfig([ }, }, { - files: ['front-end/src/**/*.{ts,tsx}', 'hub/hub-frontend/src/**/*.{ts,tsx}'], + files: [ + 'front-end/src/**/*.{ts,tsx}', + 'hub/hub-frontend/src/**/*.{ts,tsx}', + 'games/**/*.{ts,tsx}', + ], rules: { 'no-console': ['error', { allow: ['error', 'warn'] }], }, diff --git a/front-end/package.json b/front-end/package.json index 80ca8db81..e17d71f81 100644 --- a/front-end/package.json +++ b/front-end/package.json @@ -7,10 +7,11 @@ "type": "module", "packageManager": "pnpm@10.33.0", "scripts": { - "build": "pnpm exec tsc --project . && pnpm exec esbuild dist/js/index.js --bundle --sourcemap --outfile=dist/js/index-rollup.js && pnpm exec tailwindcss -i ./src/index.css -o ./dist/css/index.css", - "build:deploy": "pnpm exec tsc --project . && pnpm exec esbuild dist/js/index.js --bundle --format=esm --splitting --outdir=dist/app && pnpm exec tailwindcss -i ./src/index.css -o ./dist/app/index.css", + "generate:games": "node scripts/generate-game-registry.mjs", + "build": "pnpm run generate:games && pnpm exec tsc --project . --noEmit && pnpm exec esbuild src/index.tsx --bundle --sourcemap --jsx=automatic --outfile=dist/js/index-rollup.js --alias:@=./src --alias:@games=../games && pnpm exec tailwindcss -i ./src/index.css -o ./dist/css/index.css", + "build:deploy": "pnpm run generate:games && pnpm exec tsc --project . --noEmit && pnpm exec esbuild src/index.tsx --bundle --format=esm --splitting --jsx=automatic --outdir=dist/app --alias:@=./src --alias:@games=../games && pnpm exec tailwindcss -i ./src/index.css -o ./dist/app/index.css", "bundle": "rm -rf dist/app && pnpm run build:deploy && node scripts/assemble-bundle.mjs", - "test": "pnpm exec tsc -p tsconfig.json --noEmit && pnpm exec jest --silent=false --verbose --useStderr --ci" + "test": "pnpm run generate:games && pnpm exec tsc -p tsconfig.json --noEmit && pnpm exec jest --silent=false --verbose --useStderr --ci" }, "dependencies": { "@radix-ui/react-dialog": "1.1.23", @@ -53,21 +54,30 @@ "setupFilesAfterEnv": [ "/scripts/testSetup.ts" ], + "roots": [ + "/src", + "/../games" + ], "testMatch": [ - "/src/**/*.{spec,test}.{js,jsx,ts,tsx}" + "/src/**/*.{spec,test}.{js,jsx,ts,tsx}", + "/../games/*/ui/**/*.{spec,test}.{ts,tsx}" + ], + "testPathIgnorePatterns": [ + "/src/features/" ], - "testPathIgnorePatterns": [], "moduleDirectories": [ "node_modules", "node-pkg", "src" ], "moduleNameMapper": { + "^@/(.*)$": "/src/$1", + "^@games/(.*)$": "/../games/$1", "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/scripts/testMock.js", "\\.(css|less)$": "/scripts/testMock.js" }, "transform": { - "^.+\\.ts?$": "ts-jest" + "^.+\\.tsx?$": "ts-jest" }, "modulePathIgnorePatterns": [ "/dist" diff --git a/front-end/rebuild-fe.sh b/front-end/rebuild-fe.sh index ec7cb52f7..07ffea696 100755 --- a/front-end/rebuild-fe.sh +++ b/front-end/rebuild-fe.sh @@ -13,6 +13,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" FE_DIR="$SCRIPT_DIR" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" CLSP_DIR="$REPO_ROOT/clsp" +GAMES_DIR="$REPO_ROOT/games" # Portable millisecond nonce. macOS `date +%s%3N` leaves a literal "3N". build_nonce() { @@ -27,9 +28,7 @@ echo "=== Building chialisp (if needed) ===" "$REPO_ROOT/tools/build-chialisp.sh" echo "=== Building gaming-fe ===" -(cd "$FE_DIR" && pnpm exec tsc --project . && \ - pnpm exec esbuild dist/js/index.js --bundle --outfile=dist/js/index-rollup.js && \ - pnpm exec tailwindcss -i ./src/index.css -o ./dist/css/index.css) +(cd "$FE_DIR" && pnpm run build) echo "=== Assembling serve/ with build nonce ===" SERVE="$FE_DIR/serve" @@ -44,14 +43,23 @@ cp "$FE_DIR/public/index.html" "$SERVE/index.html" [ -f "$FE_DIR/public/favicon.svg" ] && cp "$FE_DIR/public/favicon.svg" "$SERVE/favicon.svg" cp "$FE_DIR/dist/js/index-rollup.js" "$NONCE_DIR/index.js" +[ -f "$FE_DIR/dist/js/index-rollup.js.map" ] && cp "$FE_DIR/dist/js/index-rollup.js.map" "$NONCE_DIR/index-rollup.js.map" cp "$FE_DIR/dist/css/index.css" "$NONCE_DIR/index.css" cp "$FE_DIR/dist/chia_gaming_wasm.js" "$NONCE_DIR/chia_gaming_wasm.js" cp "$FE_DIR/dist/chia_gaming_wasm_bg.wasm" "$NONCE_DIR/chia_gaming_wasm_bg.wasm" -# Match run-local-demo / assemble-bundle: games need both .hex and .dat (e.g. krunk tree). +# Match run-local-demo / assemble-bundle: core clsp plus per-game factory hex/dat. (cd "$CLSP_DIR" && find . \( -name '*.hex' -o -name '*.dat' \) | while read -r f; do mkdir -p "$NONCE_DIR/clsp/$(dirname "$f")" cp "$f" "$NONCE_DIR/clsp/$f" done) +(cd "$GAMES_DIR" && find . \( -name '*.hex' -o -name '*.dat' \) | while read -r f; do + mkdir -p "$NONCE_DIR/games/$(dirname "$f")" + cp "$f" "$NONCE_DIR/games/$f" +done) +if ! find "$NONCE_DIR/games" -name '*.hex' | grep -q .; then + echo "Error: no game factory .hex files copied into $NONCE_DIR/games" >&2 + exit 1 +fi [ -d "$FE_DIR/public/images" ] && cp -r "$FE_DIR/public/images" "$NONCE_DIR/images" # Flip the pointer only after the new nonce tree is complete. diff --git a/front-end/scripts/assemble-bundle.mjs b/front-end/scripts/assemble-bundle.mjs index 8a27a57b8..1692575bb 100644 --- a/front-end/scripts/assemble-bundle.mjs +++ b/front-end/scripts/assemble-bundle.mjs @@ -16,6 +16,8 @@ const APP = join(FE, 'dist', 'app'); // chialisp hex live. Defaults match tools/build-deploy.sh; overridable via env. const WASM_OUT_DIR = process.env.WASM_OUT_DIR || join(FE, 'dist'); const CLSP_DIR = process.env.CLSP_DIR || resolve(FE, '..', 'clsp'); +const GAMES_DIR = process.env.GAMES_DIR || resolve(FE, '..', 'games'); +const REPO_ROOT = resolve(FE, '..'); mkdirSync(APP, { recursive: true }); @@ -52,6 +54,22 @@ if (existsSync(CLSP_DIR)) { copyHex(CLSP_DIR); } +function copyGameAssets(dir) { + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + if (statSync(p).isDirectory()) { + copyGameAssets(p); + } else if (p.endsWith('.hex') || p.endsWith('.dat')) { + const dst = join(APP, relative(REPO_ROOT, p)); + mkdirSync(dirname(dst), { recursive: true }); + copyFileSync(p, dst); + } + } +} +if (existsSync(GAMES_DIR)) { + copyGameAssets(GAMES_DIR); +} + // Floor checks: fail loudly if the bundle is incomplete. const dirIsEmpty = (d) => !existsSync(d) || readdirSync(d).length === 0; const errors = []; @@ -64,6 +82,9 @@ for (const f of ['index.js', 'index.css', ...WASM_FILES]) { if (dirIsEmpty(join(APP, 'clsp'))) { errors.push('clsp/ is missing or empty (no compiled .hex)'); } +if (dirIsEmpty(join(APP, 'games'))) { + errors.push('games/ is missing or empty (no compiled factory .hex)'); +} if (dirIsEmpty(join(APP, 'images'))) { errors.push('images/ is missing or empty'); } diff --git a/front-end/scripts/generate-game-registry.mjs b/front-end/scripts/generate-game-registry.mjs new file mode 100644 index 000000000..5e9fc30a6 --- /dev/null +++ b/front-end/scripts/generate-game-registry.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +// Generates front-end/src/generated/gamePackages.ts from games/registry.json. +import { mkdirSync, readFileSync, readdirSync, existsSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const FE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(FE, '..', '..'); +const registry = JSON.parse(readFileSync(join(ROOT, 'games', 'registry.json'), 'utf8')); +const production = registry.production; +if (!Array.isArray(production) || production.length === 0) { + throw new Error('games/registry.json production list is empty'); +} + +function factoryHex(key) { + return `games/${key}/clsp/factory_${key}_factory.hex`; +} + +function extraPresets(key) { + const clsp = join(ROOT, 'games', key, 'clsp'); + try { + return readdirSync(clsp) + .filter((name) => name.endsWith('.dat')) + .map((name) => `games/${key}/clsp/${name}`); + } catch { + return []; + } +} + +function tsString(value) { + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +} + +function tsProperty(value) { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value) ? value : tsString(value); +} + +function tsArray(values, multiline = false) { + if (!multiline) return `[${values.join(', ')}]`; + return `[\n${values.map((value) => ` ${value},`).join('\n')}\n]`; +} + +const presetFiles = production.flatMap((key) => [factoryHex(key), ...extraPresets(key)]); +function relTo(key, file) { + const rel = relative(join(FE, '../src/generated'), join(ROOT, 'games', key, 'ui', file)) + .replace(/\\/g, '/') + .replace(/\.tsx?$/, ''); + return rel.startsWith('.') ? rel : `./${rel}`; +} +const imports = production + .map((key, index) => { + return [ + `import handProposal${index} from '${relTo(key, 'handProposal.ts')}';`, + `import { HandProposalForm as HandProposalForm${index} } from '${relTo(key, 'handProposalForm.tsx')}';`, + `import { play as play${index} } from '${relTo(key, 'play.tsx')}';`, + `const pkg${index} = defineGamePackage(handProposal${index}, HandProposalForm${index}, play${index});`, + ].join('\n'); + }) + .join('\n'); +const productionList = tsArray(production.map(tsString)); +const presetList = tsArray(presetFiles.map(tsString), true); +const packageMap = production.map((key, index) => ` ${tsProperty(key)}: pkg${index},`).join('\n'); + +const destDir = join(FE, '../src/generated'); +mkdirSync(destDir, { recursive: true }); + +writeFileSync( + join(destDir, 'gamePresets.ts'), + `// Generated from games/registry.json. Do not edit. +export const PRODUCTION_PACKAGE_KEYS = ${productionList} as const; +export type CatalogGameType = (typeof PRODUCTION_PACKAGE_KEYS)[number]; +export const CORE_PRESET_FILES = [ + 'clsp/unroll/unroll_puzzle_state_channel_unrolling.hex', + 'clsp/referee/onchain/referee.hex', +] as const; +export const GAME_PRESET_FILES = ${presetList} as const; +export const PRESET_FILES = [...CORE_PRESET_FILES, ...GAME_PRESET_FILES]; +`, +); + +writeFileSync( + join(destDir, 'gamePackages.ts'), + `// Generated from games/registry.json. Do not edit. +import { defineGamePackage } from '../../../games/host'; +${imports} + +export const PRODUCTION_PACKAGE_KEYS = ${productionList} as const; +export type CatalogGameType = (typeof PRODUCTION_PACKAGE_KEYS)[number]; +export const GENERATED_GAME_PACKAGES_BY_KEY = { +${packageMap} +} as const; +export const GENERATED_GAME_PACKAGES = Object.values(GENERATED_GAME_PACKAGES_BY_KEY); +export { PRESET_FILES, GAME_PRESET_FILES, CORE_PRESET_FILES } from './gamePresets'; +`, +); + +const styleImports = production.flatMap((key) => { + const styles = join(ROOT, 'games', key, 'ui/styles.css'); + if (!existsSync(styles)) return []; + const rel = relative(join(FE, '../src/generated'), styles).replace(/\\/g, '/'); + return [`@import '${rel.startsWith('.') ? rel : `./${rel}`}';`]; +}); +writeFileSync( + join(destDir, 'gameStyles.css'), + `/* Generated from games/registry.json. Do not edit. */\n${styleImports.join('\n')}${styleImports.length ? '\n' : ''}`, +); +console.log(`generate-game-registry: ${production.length} production packages`); diff --git a/front-end/src/App.tsx b/front-end/src/App.tsx index fa1321a22..2f7390e22 100644 --- a/front-end/src/App.tsx +++ b/front-end/src/App.tsx @@ -1,5 +1,20 @@ import Shell from './components/Shell'; +import { GameHostProvider } from '@games/host/ui'; +import { getCurrencyLabels } from './constants/currency'; +import { formatAmount, formatMojos } from './util'; -const App = () => ; +const hostServices = { + formatAmount, + formatMojos, + get currencyLabels() { + return getCurrencyLabels(); + }, +}; + +const App = () => ( + + + +); export default App; diff --git a/front-end/src/components/FinishedSessionGameView.tsx b/front-end/src/components/FinishedSessionGameView.tsx index 311b137d7..c220b960a 100644 --- a/front-end/src/components/FinishedSessionGameView.tsx +++ b/front-end/src/components/FinishedSessionGameView.tsx @@ -1,7 +1,7 @@ import React, { Component, Suspense } from 'react'; import type { ErrorInfo, ReactNode } from 'react'; -import type { FrozenGameMountOptions } from '../lib/gameMount'; +import type { FrozenGameMountOptions } from '@games/host'; import type { SessionModel } from '../lib/session/model'; import { selectFinishedSessionDisplay } from '../lib/session/finishedSessionDisplay'; import { renderFrozenGameMount } from '../lib/gameMountRegistry'; diff --git a/front-end/src/components/GameProposalDialogs.tsx b/front-end/src/components/GameProposalDialogs.tsx index 91f9163e5..e44751454 100644 --- a/front-end/src/components/GameProposalDialogs.tsx +++ b/front-end/src/components/GameProposalDialogs.tsx @@ -1,10 +1,12 @@ import type { UseGameSessionResult } from '../hooks/useGameSession'; -import { isValidKrunkStake } from '../features/krunk/adapter'; -import { gameDisplayName, REGISTERED_GAMES } from '../lib/gameRegistry'; +import { + describeReceivedProposal, + gameDisplayName, + packageFor, + REGISTERED_GAMES, +} from '../lib/gameRegistry'; import { composeDraftCanSubmit, composeDraftTerms } from '../lib/session/model'; -import { formatMojos } from '../util'; -import { getCurrencyLabels } from '../constants/currency'; -import { AmountInput } from './AmountInput'; +import { composeDraftValue } from '../lib/session/composeDraft'; import { Button } from './button'; export function ComposeProposalDialog({ @@ -15,20 +17,7 @@ export function ComposeProposalDialog({ maxPerHandMojos: bigint | null; }) { const compose = session.composeDraftState; - const isSpacepoker = compose.selectedGame === 'spacepoker'; - const isKrunk = compose.selectedGame === 'krunk'; - const spUnitSize = compose.spacepoker.unitSize; - const spStackSize = compose.spacepoker.stackSize; - const spBetSize = spUnitSize * spStackSize; - const spMaxUnitSize = - maxPerHandMojos != null && spStackSize > 0n ? maxPerHandMojos / spStackSize : null; - const perHandAmount = - compose.selectedGame === 'spacepoker' ? spBetSize : compose[compose.selectedGame].amount; - const krunkStakeValid = !isKrunk || isValidKrunkStake(perHandAmount); - const standardMaxMojos = - isKrunk && maxPerHandMojos != null - ? maxPerHandMojos - (maxPerHandMojos % 100n) - : maxPerHandMojos; + const pkg = packageFor(compose.selectedGame); const canSubmit = composeDraftCanSubmit(compose, maxPerHandMojos); const submit = () => { @@ -55,74 +44,13 @@ export function ComposeProposalDialog({ ))} - {isSpacepoker ? ( - <> - session.setSpacepokerComposeDraft({ unitSize })} - maxMojos={spMaxUnitSize} - onUseMax={ - spMaxUnitSize != null && spMaxUnitSize > 0n - ? () => session.setSpacepokerComposeDraft({ unitSize: spMaxUnitSize }) - : undefined - } - disabled={session.composeProposalSent} - label="Unit size" - exceedsLabel="Exceeds available reserve." - onKeyDown={(event) => { - if (event.key === 'Enter' && canSubmit) submit(); - }} - /> -
- - { - const next = event.target.value.replace(/[^0-9]/g, ''); - session.setSpacepokerComposeDraft({ stackSize: BigInt(next || '0') }); - }} - onKeyDown={(event) => { - if (event.key === 'Enter' && canSubmit) submit(); - }} - /> -
-
- Per-player stake: {formatMojos(spBetSize)} · Total game size:{' '} - {formatMojos(spBetSize * 2n)} -
- - ) : ( - 0n - ? () => - isKrunk - ? session.setKrunkComposeAmount(standardMaxMojos) - : session.setCalpokerComposeAmount(standardMaxMojos) - : undefined - } - disabled={session.composeProposalSent} - label="Per-player stake" - exceedsLabel="Exceeds available reserve." - onKeyDown={(event) => { - if (event.key === 'Enter' && canSubmit) submit(); - }} - /> - )} - {isKrunk && perHandAmount > 0n && !krunkStakeValid && ( -

- Krunk stakes must be multiples of 100 {getCurrencyLabels().mojos}. -

- )} + {pkg.renderHandProposalForm({ + draft: composeDraftValue(compose, compose.selectedGame), + disabled: session.composeProposalSent, + maxPerHandMojos, + onChange: (update) => session.updateSelectedComposeDraft(update), + onSubmit: submit, + })}

Do you want to accept this hand?

-

Game: {gameDisplayName(review.terms.gameType)}

- Per-player stake: {formatMojos(review.terms.myContribution)} + Game: {gameDisplayName(review.handProposal.gameType)}

+

{describeReceivedProposal(review.handProposal)}

- Timeout: {String(review.terms.gameTimeout)} blocks + Timeout: {String(review.handProposal.gameTimeout)} blocks

- {review.terms.gameType === 'spacepoker' && ( -

- Unit size: {formatMojos(review.terms.unitSizeMojos)} · Stack:{' '} - {String(review.terms.myContribution / review.terms.unitSizeMojos)} units -

- )}
diff --git a/front-end/src/features/calPoker/calPoker.test.ts b/games/calpoker/ui/calPoker.test.ts similarity index 78% rename from front-end/src/features/calPoker/calPoker.test.ts rename to games/calpoker/ui/calPoker.test.ts index c45582e39..46fe08e2e 100644 --- a/front-end/src/features/calPoker/calPoker.test.ts +++ b/games/calpoker/ui/calPoker.test.ts @@ -1,25 +1,28 @@ import React, { useEffect } from 'react'; import { act, create, type ReactTestRenderer } from 'react-test-renderer'; import { Program } from 'clvm-lib'; -import { EMPTY, Subject } from 'rxjs'; import { cardIdToRankSuit, handValueToDescription } from './types'; import { shouldAutoFireCalpokerMove, - shouldProcessCalpokerOpponentMoved, calpokerResponderFinishesAtReveal, shouldRestoreCalpokerSelection, useCalpokerHand, } from './useCalpokerHand'; +import { calpokerSettlementVerb, calpokerTimeoutBadge } from './settlement'; import { - calpokerSettlementVerb, - calpokerTimeoutBadge, + EMPTY_GAME_TERMINAL_MODEL, isForfeitOutcome, -} from '../../lib/settlement'; -import type { SessionController } from '../../hooks/SessionController'; -import { calpokerStateCodec } from './stateCodec'; -import { INITIAL_GAME_TERMINAL_MODEL } from '../../lib/session/model'; -import type { GameHandOrigin } from '../../lib/gameMount'; -import type { GameplayEvent } from '../../lib/session/gameSessionEvents'; + type GameHandOrigin, + type GameHandSource, + type GameIntent, + type LiveGamePort, + type PersistedGameState, +} from '../../host'; +import { + calpokerStateCodec, + reduceCalpokerDurableState, + type CalpokerHandState, +} from './serialize'; import CaliforniaPoker from './components/CaliforniaPoker'; import { GAME_STATES, @@ -28,7 +31,6 @@ import { } from './components/constants/constants'; import { CalpokerOutcome, projectCalpokerFinalDisplay } from './outcome'; import type { CaliforniapokerProps, CalpokerOutcomeView } from './types/CaliforniapokerProps'; -import type { LocalGameActionRequest } from '../../lib/session/sessionMachineTypes'; jest.mock('./components/components/GameBottomBar', () => () => null); jest.mock('./components/components', () => { @@ -44,12 +46,31 @@ jest.mock('./components/components', () => { }; }); -function makeLocalActionCommit(makeMove: jest.Mock) { - return (request: LocalGameActionRequest) => { - if (request.command.type !== 'make-move') { - throw new Error(`Unexpected test command ${request.command.type}`); +type TestLiveGamePort = LiveGamePort & { + handState: PersistedGameState; +}; + +function makeDispatch( + makeMove: jest.Mock, + applyState: (state: CalpokerHandState) => void = () => {}, +) { + return (intent: GameIntent) => { + applyState(intent.state); + if (intent.type === 'update-local-state') return; + if (intent.type !== 'make-move') { + throw new Error(`Unexpected test intent ${intent.type}`); } - makeMove(request.id, request.command.readable); + makeMove(intent.gameId, intent.readable); + }; +} + +function liveSource(port: TestLiveGamePort): GameHandSource { + return { + interactionMode: 'live', + get handState() { + return port.handState; + }, + port, }; } @@ -71,11 +92,6 @@ describe('Calpoker bigint domain helpers', () => { expect(shouldAutoFireCalpokerMove(false, true, 2n)).toBe(true); }); - it('still accepts a late final readable move after terminal if no outcome was shown', () => { - expect(shouldProcessCalpokerOpponentMoved(true, false)).toBe(true); - expect(shouldProcessCalpokerOpponentMoved(true, true)).toBe(false); - }); - it('at the endgame reveal, only the responder finishes; the terminal mover (Alice) still plays step e', () => { // iStarted === false is the first mover ("Alice"), who owes the terminal // move e and must NOT be marked finished, or her autofire never fires. @@ -125,7 +141,7 @@ describe('Calpoker fresh hand startup', () => { it('submits the opening nil move when fresh durable state is already installed', () => { const makeMove = jest.fn(); - const commitLocalGameAction = jest.fn(makeLocalActionCommit(makeMove)); + const dispatch = jest.fn(makeDispatch(makeMove)); const controller = { handState: calpokerStateCodec.encode({ playerHand: [], @@ -133,23 +149,15 @@ describe('Calpoker fresh hand startup', () => { cardSelections: [], moveNumber: 0n, isPlayerTurn: true, + iStarted: false, + error: null, }), isChannelReady: () => true, - transitionFeatureState: () => true, - commitLocalGameAction, - makeMove, - } as unknown as SessionController; + dispatch, + }; function Harness() { - useCalpokerHand( - { interactionMode: 'live', controller }, - '7', - false, - EMPTY, - () => {}, - () => {}, - INITIAL_GAME_TERMINAL_MODEL, - ); + useCalpokerHand(liveSource(controller), '7', false, EMPTY_GAME_TERMINAL_MODEL); return null; } @@ -159,18 +167,17 @@ describe('Calpoker fresh hand startup', () => { expect(makeMove).toHaveBeenCalledTimes(1); expect(makeMove).toHaveBeenCalledWith('7', null); - expect(commitLocalGameAction).toHaveBeenCalledWith( + expect(dispatch).toHaveBeenCalledWith( expect.objectContaining({ - gameType: 'calpoker', - id: '7', - command: { type: 'make-move', readable: null }, + type: 'make-move', + gameId: '7', + readable: null, }), ); }); it('does not project or submit when the session rejects the opening state commit', () => { const makeMove = jest.fn(); - const onTurnChanged = jest.fn(); const controller = { handState: calpokerStateCodec.encode({ playerHand: [], @@ -178,24 +185,16 @@ describe('Calpoker fresh hand startup', () => { cardSelections: [], moveNumber: 0n, isPlayerTurn: true, + iStarted: false, + error: null, }), isChannelReady: () => true, - transitionFeatureState: () => false, - commitLocalGameAction: () => { + dispatch: () => { throw new Error('opening rejected'); }, - makeMove, - } as unknown as SessionController; + }; function Harness() { - useCalpokerHand( - { interactionMode: 'live', controller }, - '7', - false, - EMPTY, - () => {}, - onTurnChanged, - INITIAL_GAME_TERMINAL_MODEL, - ); + useCalpokerHand(liveSource(controller), '7', false, EMPTY_GAME_TERMINAL_MODEL); return null; } @@ -205,7 +204,6 @@ describe('Calpoker fresh hand startup', () => { }), ).toThrow('opening rejected'); expect(makeMove).not.toHaveBeenCalled(); - expect(onTurnChanged).not.toHaveBeenCalled(); }); it('does not replay the opening nil move when mounting a restored session', () => { @@ -217,24 +215,15 @@ describe('Calpoker fresh hand startup', () => { cardSelections: [], moveNumber: 0n, isPlayerTurn: true, + iStarted: false, + error: null, }), isChannelReady: () => true, - transitionFeatureState: () => true, - commitLocalGameAction: makeLocalActionCommit(makeMove), - makeMove, - } as unknown as SessionController; + dispatch: makeDispatch(makeMove), + }; function Harness() { - useCalpokerHand( - { interactionMode: 'live', controller }, - '7', - false, - EMPTY, - () => {}, - () => {}, - INITIAL_GAME_TERMINAL_MODEL, - 'restored', - ); + useCalpokerHand(liveSource(controller), '7', false, EMPTY_GAME_TERMINAL_MODEL, 'restored'); return null; } @@ -254,25 +243,15 @@ describe('Calpoker fresh hand startup', () => { cardSelections: [], moveNumber: 0n, isPlayerTurn: true, + iStarted: false, + error: null, }), - getRestoreStatus: () => 'restored', isChannelReady: () => true, - transitionFeatureState: () => true, - commitLocalGameAction: makeLocalActionCommit(makeMove), - makeMove, - } as unknown as SessionController; + dispatch: makeDispatch(makeMove), + }; function Harness({ gameId, handOrigin }: { gameId: string; handOrigin: GameHandOrigin }) { - useCalpokerHand( - { interactionMode: 'live', controller }, - gameId, - false, - EMPTY, - () => {}, - () => {}, - INITIAL_GAME_TERMINAL_MODEL, - handOrigin, - ); + useCalpokerHand(liveSource(controller), gameId, false, EMPTY_GAME_TERMINAL_MODEL, handOrigin); return null; } const mount = (key: number, gameId: string, handOrigin: GameHandOrigin) => @@ -291,6 +270,103 @@ describe('Calpoker fresh hand startup', () => { }); }); +describe('Calpoker move rejection feedback', () => { + it('preserves delayed canonical gameplay state and displays the rejection', () => { + const current: CalpokerHandState = { + playerHand: [0n, 1n], + opponentHand: [2n, 3n], + cardSelections: [0n], + moveNumber: 1n, + isPlayerTurn: true, + iStarted: false, + error: null, + }; + const next = reduceCalpokerDurableState(current, { + type: 'move-rejected', + gameId: '7', + tag: 'ui_protocol_mismatch', + message: 'California Poker move was rejected.', + }); + + expect(next).toEqual({ + ...current, + error: { + tag: 'ui_protocol_mismatch', + message: 'California Poker move was rejected.', + }, + }); + + let renderer: ReactTestRenderer; + act(() => { + renderer = create( + React.createElement(CaliforniaPoker, { + outcome: undefined, + moveNumber: '1', + playerNumber: 1, + playerHand: ['0', '1'], + opponentHand: ['2', '3'], + cardSelections: ['0'], + setCardSelections: () => {}, + setHandOrder: () => {}, + handleMakeMove: () => {}, + onGameLog: () => {}, + onSnapshotChange: () => {}, + error: next!.error, + interactionMode: 'terminal', + }), + ); + }); + expect( + renderer!.root.findAll( + (node) => node.props.children === 'California Poker move was rejected.', + ), + ).toHaveLength(1); + act(() => renderer!.unmount()); + }); + + it('clears rejection feedback in the next valid local move candidate', () => { + const makeMove = jest.fn(); + const controller = { + handState: calpokerStateCodec.encode({ + playerHand: [0n, 1n, 2n, 3n], + opponentHand: [4n, 5n, 6n, 7n], + cardSelections: [0n, 1n, 2n, 3n], + moveNumber: 1n, + isPlayerTurn: true, + iStarted: false, + error: { tag: 'ui_protocol_mismatch', message: 'Rejected.' }, + }), + isChannelReady: () => true, + dispatch: jest.fn(makeDispatch(makeMove)), + }; + let hand: ReturnType | undefined; + let renderer: ReactTestRenderer; + function Harness() { + hand = useCalpokerHand( + liveSource(controller), + '7', + false, + EMPTY_GAME_TERMINAL_MODEL, + 'restored', + ); + return null; + } + + act(() => { + renderer = create(React.createElement(Harness)); + }); + act(() => hand!.handleMakeMove()); + + expect(controller.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'make-move', + state: expect.objectContaining({ error: null }), + }), + ); + act(() => renderer!.unmount()); + }); +}); + describe('Calpoker terminal hand projection', () => { let renderer: ReactTestRenderer | null = null; @@ -311,29 +387,29 @@ describe('Calpoker terminal hand projection', () => { cardSelections: playerHand.slice(0, 4), moveNumber: 2n, isPlayerTurn: true, + iStarted: false, + error: null, }), isChannelReady: () => true, - transitionFeatureState: (_gameType: string, _gameId: string, state: unknown) => { - if (!calpokerStateCodec.isState(state)) { - rejectedPayloads.push(state); - return false; + dispatch: (intent: GameIntent) => { + if (!calpokerStateCodec.isState(intent.state)) { + rejectedPayloads.push(intent.state); + throw new Error('Calpoker test received invalid local action state'); + } + controller.handState = calpokerStateCodec.encode(intent.state); + if (intent.type === 'make-move') { + makeMove(intent.gameId, intent.readable); } - return true; }, - commitLocalGameAction: makeLocalActionCommit(makeMove), - makeMove, - } as unknown as SessionController; + }; let hand: ReturnType | undefined; function Harness() { hand = useCalpokerHand( - { interactionMode: 'live', controller }, + liveSource(controller), '7', false, - EMPTY, - () => {}, - () => {}, - INITIAL_GAME_TERMINAL_MODEL, + EMPTY_GAME_TERMINAL_MODEL, 'restored', ); return null; @@ -348,6 +424,7 @@ describe('Calpoker terminal hand projection', () => { [...playerHand.slice(0, 4), ...opponentHand.slice(4)], ); hand!.handleMakeMove(); + renderer!.update(React.createElement(Harness)); }); expect(rejectedPayloads).toEqual([]); @@ -527,7 +604,7 @@ describe('Calpoker terminal hand projection', () => { Program.fromList([1n, 1n, 1n, 1n, 1n, 10n, 9n, 8n, 7n, 6n].map(Program.fromBigInt)), Program.fromBigInt(-1n), ]).serialize(); - const transitionFeatureState = jest.fn(() => true); + const dispatch = jest.fn(); const makeMove = jest.fn(); const controller = { handState: calpokerStateCodec.encode({ @@ -536,14 +613,12 @@ describe('Calpoker terminal hand projection', () => { cardSelections: selections, moveNumber: 2n, isPlayerTurn: false, + iStarted: false, + error: null, }), isChannelReady: () => true, - transitionFeatureState, - commitLocalGameAction: makeLocalActionCommit(makeMove), - makeMove, - } as unknown as SessionController; - const gameplay = new Subject(); - const onOutcome = jest.fn(); + dispatch, + }; const mountCount = jest.fn(); function Harness({ terminalOutcome }: { terminalOutcome: 'forfeited_skipped_reveal' | null }) { @@ -552,13 +627,13 @@ describe('Calpoker terminal hand projection', () => { }, []); const hand = useCalpokerHand( terminalOutcome === null - ? { interactionMode: 'live', controller } - : { interactionMode: 'terminal', handState: controller.handState }, + ? liveSource(controller) + : { + interactionMode: 'terminal', + handState: controller.handState, + }, '7', false, - gameplay, - onOutcome, - () => {}, terminalOutcome ? { type: 'settled', @@ -567,7 +642,7 @@ describe('Calpoker terminal hand projection', () => { myReward: '0', rewardCoinHex: null, } - : INITIAL_GAME_TERMINAL_MODEL, + : EMPTY_GAME_TERMINAL_MODEL, 'restored', ); const outcomeViewValue: CalpokerOutcomeView | undefined = hand.outcome @@ -618,32 +693,25 @@ describe('Calpoker terminal hand projection', () => { renderer = create(React.createElement(Harness, { terminalOutcome: null })); }); act(() => { - gameplay.next({ - OpponentMoved: { - gameId: '7', - readable: finalReadable, - moverShare: '0', - }, + const current = calpokerStateCodec.decode(controller.handState)!; + const next = reduceCalpokerDurableState(current, { + type: 'opponent-moved', + gameId: '7', + readable: finalReadable, + moverShare: '0', + iStarted: false, }); + controller.handState = calpokerStateCodec.encode(next!); renderer!.update( React.createElement(Harness, { terminalOutcome: 'forfeited_skipped_reveal', }), ); - gameplay.next({ - Settled: { - gameId: '7', - outcome: 'forfeited_skipped_reveal', - ourShare: '0', - }, - }); }); const presentation = () => renderer!.root.find((node) => node.props['data-calpoker-game-state'] !== undefined); expect(mountCount).toHaveBeenCalledTimes(1); - expect(onOutcome).toHaveBeenCalledTimes(1); - expect(onOutcome.mock.calls[0][0].my_win_outcome).toBe('lose'); expect(presentation().props['data-calpoker-game-state']).toBe(GAME_STATES.REVEALING_SWAP); expect(presentation().props['data-calpoker-interaction-mode']).toBe('terminal'); @@ -671,7 +739,7 @@ describe('Calpoker terminal hand projection', () => { expect(markup).toContain('Bob wins ('); expect(markup).toContain('Alice loses ('); expect(markup).toContain('forfeit'); - expect(transitionFeatureState).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); expect(makeMove).not.toHaveBeenCalled(); } finally { jest.useRealTimers(); diff --git a/front-end/src/features/calPoker/components/CaliforniaPoker.tsx b/games/calpoker/ui/components/CaliforniaPoker.tsx similarity index 98% rename from front-end/src/features/calPoker/components/CaliforniaPoker.tsx rename to games/calpoker/ui/components/CaliforniaPoker.tsx index 1206d113e..c90a74d73 100644 --- a/front-end/src/features/calPoker/components/CaliforniaPoker.tsx +++ b/games/calpoker/ui/components/CaliforniaPoker.tsx @@ -20,11 +20,8 @@ import { HandDisplay, MovingCard } from './components'; import { SuitName } from '../types/CardValueSuit'; import { CalpokerDisplaySnapshotView, CalpokerOutcomeView } from '../types/CaliforniapokerProps'; import GameBottomBar from './components/GameBottomBar'; -import { - calpokerSettlementVerb, - calpokerTimeoutBadge, - settlementByUs, -} from '../../../lib/settlement'; +import { settlementByUs } from '../../../host'; +import { calpokerSettlementVerb, calpokerTimeoutBadge } from '../settlement'; import { shouldRestoreCalpokerSelection } from '../useCalpokerHand'; import { projectCalpokerFinalDisplay } from '../outcome'; @@ -61,6 +58,7 @@ const CaliforniaPoker: React.FC = ({ opponentName, terminalOutcome, interactionMode = 'live', + error, }) => { const interactive = interactionMode === 'live'; const settlementByUsFlag = terminalOutcome == null ? null : settlementByUs(terminalOutcome); @@ -595,6 +593,11 @@ const CaliforniaPoker: React.FC = ({ data-calpoker-interaction-mode={interactionMode} className="relative flex flex-col w-full text-canvas-text" > + {error && ( +

+ {error.message} +

+ )}
{/* Hands region */}
diff --git a/front-end/src/features/calPoker/components/components/Card.tsx b/games/calpoker/ui/components/components/Card.tsx similarity index 93% rename from front-end/src/features/calPoker/components/components/Card.tsx rename to games/calpoker/ui/components/components/Card.tsx index 13c3b3619..2587eb31f 100644 --- a/front-end/src/features/calPoker/components/components/Card.tsx +++ b/games/calpoker/ui/components/components/Card.tsx @@ -1,3 +1,4 @@ +import type { CSSProperties } from 'react'; import { CardRenderProps } from '../../types'; import { SUIT_COLORS, HALO_FADE_DURATION_MS } from '../constants/constants'; import CardContent from './CardContent'; @@ -46,7 +47,7 @@ function Card(props: CardRenderProps) { id={id} data-card-id={cardId} className={`card-face ${stateClass} relative z-10 w-full aspect-[5/7] rounded-lg flex flex-col items-center justify-center font-bold ${cursor}`} - style={{ '--suit-color': suitColor } as React.CSSProperties} + style={{ '--suit-color': suitColor } as CSSProperties} onClick={onClick} > {!isHidden && } diff --git a/front-end/src/features/calPoker/components/components/CardContent.tsx b/games/calpoker/ui/components/components/CardContent.tsx similarity index 100% rename from front-end/src/features/calPoker/components/components/CardContent.tsx rename to games/calpoker/ui/components/components/CardContent.tsx diff --git a/front-end/src/features/calPoker/components/components/GameBottomBar.tsx b/games/calpoker/ui/components/components/GameBottomBar.tsx similarity index 54% rename from front-end/src/features/calPoker/components/components/GameBottomBar.tsx rename to games/calpoker/ui/components/components/GameBottomBar.tsx index 947f58b35..1020771f2 100644 --- a/front-end/src/features/calPoker/components/components/GameBottomBar.tsx +++ b/games/calpoker/ui/components/components/GameBottomBar.tsx @@ -1,5 +1,3 @@ -import { Button } from '@/src/components/button'; - interface GameBottomBarProps { buttonText: string; isDisabled: boolean; @@ -8,15 +6,14 @@ interface GameBottomBarProps { const GameBottomBar = ({ buttonText, isDisabled, doHandleMakeMove }: GameBottomBarProps) => { return ( - + ); }; diff --git a/front-end/src/features/calPoker/components/components/HandDisplay.tsx b/games/calpoker/ui/components/components/HandDisplay.tsx similarity index 99% rename from front-end/src/features/calPoker/components/components/HandDisplay.tsx rename to games/calpoker/ui/components/components/HandDisplay.tsx index ef7b511e9..5bfb73b1c 100644 --- a/front-end/src/features/calPoker/components/components/HandDisplay.tsx +++ b/games/calpoker/ui/components/components/HandDisplay.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; +import { useEffect, useRef, useState, useCallback, type CSSProperties } from 'react'; import { HandDisplayProps } from '../../types'; import { CardValueSuit } from '../../types/CardValueSuit'; import { GAME_STATES, HALO_FADE_DURATION_MS } from '../constants/constants'; @@ -684,7 +684,7 @@ function HandDisplay(props: HandDisplayProps) { position: 'relative', opacity: cards.length > 0 ? 1 : 0, transition: `opacity ${HALO_FADE_DURATION_MS}ms ease-in-out`, - } as React.CSSProperties; + } as CSSProperties; const placeholderSlots: HoleSlot[] = cards.length === 0 ? new Array(EXPECTED_HAND_SIZE).fill(null) : []; const visibleSlots = holeSlots ?? (cards.length > 0 ? cards : placeholderSlots); diff --git a/front-end/src/features/calPoker/components/components/MovingCard.tsx b/games/calpoker/ui/components/components/MovingCard.tsx similarity index 93% rename from front-end/src/features/calPoker/components/components/MovingCard.tsx rename to games/calpoker/ui/components/components/MovingCard.tsx index cfaf35107..dbd031e2b 100644 --- a/front-end/src/features/calPoker/components/components/MovingCard.tsx +++ b/games/calpoker/ui/components/components/MovingCard.tsx @@ -1,3 +1,4 @@ +import type { CSSProperties } from 'react'; import { MovingCardProps } from '../../types'; import { SUIT_COLORS, SWAP_MOVE_DURATION_MS } from '../constants/constants'; import CardContent from './CardContent'; @@ -19,7 +20,7 @@ function MovingCard(props: MovingCardProps) { '--end-x': `${endX}px`, '--end-y': `${endY}px`, animationDuration: `${SWAP_MOVE_DURATION_MS}ms`, - } as React.CSSProperties; + } as CSSProperties; return (
= { + decode(value) { + const program = readClvmProgram(value); + if (!program) return null; + const items = readClvmList(program, 2); + if (!items) return null; + const perPlayerStake = readClvmAtom(items[0]); + const senderGoesFirst = readClvmFlag(items[1]); + if (perPlayerStake === null || perPlayerStake <= 0n || senderGoesFirst === null) return null; + return { perPlayerStake, senderGoesFirst }; + }, + encode(params) { + return Program.fromList([ + Program.fromBigInt(params.perPlayerStake), + Program.fromBigInt(params.senderGoesFirst ? 1n : 0n), + ]); + }, +}; + +export function validateCalpokerHandProposal(handProposal: HandProposal): boolean { + return ( + handProposal.myContribution === handProposal.theirContribution && + handProposal.myContribution > 0n && + handProposal.gameTimeout > 0n + ); +} + +const registration: GameFeatureRegistration< + CalpokerHandState, + CalpokerHandState, + { amount: bigint }, + CalpokerFactoryParameters +> = { + gameType: 'calpoker', + displayName: 'California Poker', + stateCodec: calpokerStateCodec, + factoryParameters: calpokerFactoryParameters, + describeHandProposal: (handProposal, { formatMojos }) => + `Stake ${formatMojos(handProposal.myContribution)} each`, + handMembershipDescription: 'exactly one currentHandGameId', + validateHandMembership: (gameIds) => gameIds.length === 1, + decodeFeatureState: (value) => (calpokerStateCodec.isState(value) ? value : null), + selectOutcome: (state) => + state.outcome ? { my_win_outcome: state.outcome.my_win_outcome } : null, + lifecycle: { + proposalSenderGoesFirst: (iStarted) => !iStarted, + }, + draft: { + default: (perGameAmount) => ({ amount: perGameAmount }), + fromHandProposal: (handProposal) => ({ amount: handProposal.myContribution }), + update: (current, update) => ({ ...current, ...update }), + toHandProposal(draft, gameTimeout) { + const handProposal = { + gameType: 'calpoker', + myContribution: draft.amount, + theirContribution: draft.amount, + gameTimeout, + }; + return validateCalpokerHandProposal(handProposal) ? handProposal : null; + }, + }, + toFactoryParameters(handProposal, iStarted) { + return { + perPlayerStake: handProposal.myContribution, + senderGoesFirst: this.lifecycle.proposalSenderGoesFirst(iStarted), + }; + }, + decodeHandProposal(base, params, context) { + if ( + params.perPlayerStake !== base.myContribution || + params.senderGoesFirst !== context.expectedSenderGoesFirst + ) { + return null; + } + const handProposal = { gameType: 'calpoker', ...base }; + return validateCalpokerHandProposal(handProposal) ? handProposal : null; + }, + validateHandProposal: validateCalpokerHandProposal, + handProposalsEqual: equalHandProposalBase, + persistence: { + encodeExtras: () => ({}), + decodeExtras(base) { + const handProposal = { gameType: 'calpoker', ...base }; + return validateCalpokerHandProposal(handProposal) ? handProposal : null; + }, + }, + durableState: { + initialize(current, input) { + return reduceCalpokerDurableState(current, input)!; + }, + reduceInput(current, input) { + return reduceCalpokerDurableState(current, input)!; + }, + applyFeatureState: (_current, _gameId, state) => state, + }, +}; + +export const calpokerRegistration = registration; +export default registration; diff --git a/games/calpoker/ui/handProposalForm.tsx b/games/calpoker/ui/handProposalForm.tsx new file mode 100644 index 000000000..79dddcf7e --- /dev/null +++ b/games/calpoker/ui/handProposalForm.tsx @@ -0,0 +1,29 @@ +import { AmountInput } from '../../host/ui'; +import type { HandProposalFormProps } from '../../host'; + +export function HandProposalForm({ + draft, + disabled, + maxPerHandMojos, + onChange, + onSubmit, +}: HandProposalFormProps<{ amount: bigint }>) { + return ( + onChange({ amount })} + maxMojos={maxPerHandMojos} + onUseMax={ + maxPerHandMojos != null && maxPerHandMojos > 0n + ? () => onChange({ amount: maxPerHandMojos }) + : undefined + } + disabled={disabled} + label="Per-player stake" + exceedsLabel="Exceeds available reserve." + onKeyDown={(event) => { + if (event.key === 'Enter') onSubmit(); + }} + /> + ); +} diff --git a/front-end/src/features/calPoker/outcome.ts b/games/calpoker/ui/outcome.ts similarity index 100% rename from front-end/src/features/calPoker/outcome.ts rename to games/calpoker/ui/outcome.ts diff --git a/front-end/src/features/calPoker/LiveMount.tsx b/games/calpoker/ui/play.tsx similarity index 55% rename from front-end/src/features/calPoker/LiveMount.tsx rename to games/calpoker/ui/play.tsx index 49300f77f..1f352e966 100644 --- a/front-end/src/features/calPoker/LiveMount.tsx +++ b/games/calpoker/ui/play.tsx @@ -1,29 +1,37 @@ import { lazy, useCallback } from 'react'; -import { EMPTY, type Observable } from 'rxjs'; -import type { GameplayEvent } from '../../hooks/useGameSession'; -import type { GameHandSource, GameHandOrigin, GameMountRegistration } from '../../lib/gameMount'; -import { terminalGameHandSource } from '../../lib/gameMount'; -import { formatAmount } from '../../util'; +import { + EMPTY_GAME_TERMINAL_MODEL, + gameHandSourceFromMountView, + type GameHandOrigin, + type GameHandSource, + type GameMountRegistration, + type GameTerminalModel, +} from '../../host'; +import { useGameHost } from '../../host/ui'; import type { CalpokerDisplaySnapshotView, CalpokerOutcomeView, } from './types/CaliforniapokerProps'; import { useCalpokerHand } from './useCalpokerHand'; -import type { CalpokerDisplaySnapshot } from './stateCodec'; -import type { CalpokerOutcome } from './outcome'; -import type { GameTerminalModel } from '../../lib/session/types'; +import type { CalpokerDisplaySnapshot } from './serialize'; +import type { CalpokerOutcomeShape } from './outcome'; -const Calpoker = lazy(() => import('./index')); +const Calpoker = lazy(() => import('./Calpoker')); + +function amountForGame(amountsById: Record, gameId: string): bigint { + const amount = amountsById[gameId]; + if (amount === undefined) { + throw new Error(`California Poker is missing the accepted amount for game ${gameId}`); + } + return BigInt(amount); +} export interface CalpokerLiveMountProps { handSource: GameHandSource; gameId: string; iStarted: boolean; playerNumber: number; - gameplayEvent$: Observable; - onOutcome: (outcome: CalpokerOutcome) => void; - onTurnChanged: (gameId: string, isMyTurn: boolean) => void; - appendGameLog: (line: string) => void; + appendGameLog?: (line: string) => void; perGameAmount: bigint; myName?: string; opponentName?: string; @@ -54,7 +62,9 @@ function snapshotModel(snapshot: CalpokerDisplaySnapshotView): CalpokerDisplaySn }; } -function outcomeView(outcome: CalpokerOutcome | undefined): CalpokerOutcomeView | undefined { +function outcomeView( + outcome: CalpokerOutcomeShape | undefined, +): CalpokerOutcomeView | undefined { if (!outcome) return undefined; return { my_win_outcome: outcome.my_win_outcome, @@ -75,9 +85,6 @@ export function CalpokerLiveMount(props: CalpokerLiveMountProps) { gameId, iStarted, playerNumber, - gameplayEvent$, - onOutcome, - onTurnChanged, appendGameLog, perGameAmount, myName, @@ -85,27 +92,16 @@ export function CalpokerLiveMount(props: CalpokerLiveMountProps) { terminal, handOrigin = 'fresh', } = props; - const handleTurnChanged = useCallback( - (isMyTurn: boolean) => onTurnChanged(gameId, isMyTurn), - [gameId, onTurnChanged], - ); - const hand = useCalpokerHand( - handSource, - gameId, - iStarted, - gameplayEvent$, - onOutcome, - handleTurnChanged, - terminal, - handOrigin, - ); + const { formatAmount } = useGameHost(); + const hand = useCalpokerHand(handSource, gameId, iStarted, terminal, handOrigin); const handleGameLog = useCallback( (lines: string[]) => { + if (!appendGameLog) return; appendGameLog(`California Poker ${formatAmount(perGameAmount)}`); lines.forEach(appendGameLog); appendGameLog(''); }, - [appendGameLog, perGameAmount], + [appendGameLog, formatAmount, perGameAmount], ); return ( @@ -127,7 +123,6 @@ export function CalpokerLiveMount(props: CalpokerLiveMountProps) { } handleMakeMove={hand.handleMakeMove} handleCheat={hand.handleCheat} - handleNerf={hand.handleNerf} onGameLog={handleGameLog} onSnapshotChange={(snapshot) => hand.saveDisplaySnapshot(snapshotModel(snapshot))} initialSnapshot={snapshotView(hand.initialDisplaySnapshot)} @@ -135,63 +130,34 @@ export function CalpokerLiveMount(props: CalpokerLiveMountProps) { opponentName={opponentName} terminalOutcome={hand.terminalOutcome} interactionMode={handSource.interactionMode} + error={hand.error} /> ); } -export const calpokerMountRegistration: GameMountRegistration = { - renderLive(session, names) { - const gameId = session.activeGameId ?? session.gameSpecificView.displayGameId ?? ''; - return ( - - ); - }, - renderFrozen(model, options) { +export const play: GameMountRegistration = { + render(view) { const gameId = - model.game.lastDisplayedId ?? - model.game.currentHandIds[0] ?? - model.game.activeIds[0] ?? - 'finished'; + view.activeIds[0] ?? view.lastDisplayedId ?? view.currentHandIds[0] ?? 'finished'; + const source = gameHandSourceFromMountView(view); return ( {}} - onTurnChanged={() => {}} - appendGameLog={() => {}} - perGameAmount={model.betweenHand.lastTerms.myContribution} - terminal={model.game.instances[gameId]?.terminal ?? emptyFinishedTerminal()} - handOrigin="terminal" - myName={options.myName} - opponentName={options.opponentName} + iStarted={view.iStarted} + playerNumber={view.playerNumber} + appendGameLog={view.frozen ? undefined : view.appendGameLog} + perGameAmount={amountForGame( + Object.fromEntries( + Object.entries(view.instances).map(([id, instance]) => [id, instance.amount]), + ), + gameId, + )} + terminal={view.instances[gameId]?.terminal ?? EMPTY_GAME_TERMINAL_MODEL} + handOrigin={view.handOrigin} + myName={view.myName} + opponentName={view.opponentName} /> ); }, }; - -function emptyFinishedTerminal(): GameTerminalModel { - return { - type: 'none', - outcome: null, - label: null, - myReward: null, - rewardCoinHex: null, - }; -} diff --git a/games/calpoker/ui/serialize.ts b/games/calpoker/ui/serialize.ts new file mode 100644 index 000000000..e73a9c5d0 --- /dev/null +++ b/games/calpoker/ui/serialize.ts @@ -0,0 +1,277 @@ +import { Program } from 'clvm-lib'; +import { defineGameStateCodec, type GameInput } from '../../host'; +import { CalpokerOutcome, projectCalpokerFinalDisplay, type CalpokerOutcomeShape } from './outcome'; + +export interface CalpokerDisplaySnapshot { + gameState: string; + winner: string | null; + playerBestHandCardIds: bigint[]; + opponentBestHandCardIds: bigint[]; + playerHaloCardIds: bigint[]; + opponentHaloCardIds: bigint[]; + playerDisplayText: string; + opponentDisplayText: string; +} + +export interface CalpokerError { + tag: string; + message: string; +} + +export interface CalpokerHandState { + playerHand: bigint[]; + opponentHand: bigint[]; + moveNumber: bigint; + isPlayerTurn: boolean; + iStarted: boolean; + cardSelections?: bigint[]; + displaySnapshot?: CalpokerDisplaySnapshot; + outcome?: CalpokerOutcomeShape; + error: CalpokerError | null; +} + +function isCardArray(value: unknown): value is bigint[] { + return ( + Array.isArray(value) && + value.every((item) => typeof item === 'bigint' && item >= 0n && item < 52n) && + new Set(value).size === value.length + ); +} + +function isDisplaySnapshot(value: unknown): value is CalpokerDisplaySnapshot { + if (typeof value !== 'object' || value === null) return false; + const snapshot = value as Partial; + return ( + typeof snapshot.gameState === 'string' && + (snapshot.winner === null || typeof snapshot.winner === 'string') && + isCardArray(snapshot.playerBestHandCardIds) && + isCardArray(snapshot.opponentBestHandCardIds) && + isCardArray(snapshot.playerHaloCardIds) && + isCardArray(snapshot.opponentHaloCardIds) && + typeof snapshot.playerDisplayText === 'string' && + typeof snapshot.opponentDisplayText === 'string' + ); +} + +function isBigintArray(value: unknown): value is bigint[] { + return Array.isArray(value) && value.every((item) => typeof item === 'bigint'); +} + +function isCalpokerOutcome(value: unknown): value is CalpokerOutcomeShape { + if (typeof value !== 'object' || value === null) return false; + const outcome = value as Partial>; + return ( + (outcome.my_win_outcome === 'win' || + outcome.my_win_outcome === 'lose' || + outcome.my_win_outcome === 'tie') && + isCardArray(outcome.my_cards) && + isCardArray(outcome.their_cards) && + isCardArray(outcome.my_final_hand) && + isCardArray(outcome.their_final_hand) && + isCardArray(outcome.my_used_cards) && + isCardArray(outcome.their_used_cards) && + isBigintArray(outcome.my_hand_value) && + isBigintArray(outcome.their_hand_value) + ); +} + +function isCalpokerError(value: unknown): value is CalpokerError { + if (typeof value !== 'object' || value === null) return false; + const error = value as Partial; + return ( + Object.keys(value).length === 2 && + typeof error.tag === 'string' && + /^[a-z][a-z0-9_]*$/.test(error.tag) && + typeof error.message === 'string' && + error.message.length > 0 + ); +} + +function isCalpokerHandState(value: unknown): value is CalpokerHandState { + if (typeof value !== 'object' || value === null) return false; + const state = value as Partial; + if (!isCardArray(state.playerHand) || !isCardArray(state.opponentHand)) return false; + if (state.playerHand.length !== state.opponentHand.length) { + return false; + } + if (new Set([...state.playerHand, ...state.opponentHand]).size !== state.playerHand.length * 2) { + return false; + } + if ( + state.cardSelections !== undefined && + (!isCardArray(state.cardSelections) || + state.cardSelections.length > 4 || + state.cardSelections.some((card) => !state.playerHand!.includes(card))) + ) { + return false; + } + return ( + typeof state.moveNumber === 'bigint' && + state.moveNumber >= 0n && + state.moveNumber <= 3n && + typeof state.isPlayerTurn === 'boolean' && + typeof state.iStarted === 'boolean' && + (state.displaySnapshot === undefined || isDisplaySnapshot(state.displaySnapshot)) && + (state.outcome === undefined || isCalpokerOutcome(state.outcome)) && + (state.error === null || isCalpokerError(state.error)) + ); +} + +export const calpokerStateCodec = defineGameStateCodec({ + gameType: 'calpoker', + version: 3n, + canRemountFinished: true, + isState: isCalpokerHandState, +}); + +function initialState(isMyTurn: boolean, iStarted: boolean): CalpokerHandState { + return { + playerHand: [], + opponentHand: [], + cardSelections: [], + moveNumber: 0n, + isPlayerTurn: isMyTurn, + iStarted, + error: null, + }; +} + +function cardsFromReadable( + readable: Uint8Array, + iStarted: boolean, +): Pick { + const lists = Program.deserialize(readable) + .toList() + .map((list) => list.toList().map((card) => card.toBigInt())); + return iStarted + ? { playerHand: lists[1], opponentHand: lists[0] } + : { playerHand: lists[0], opponentHand: lists[1] }; +} + +type CalpokerFeatureEvent = + | { type: 'opponent-moved'; readable: Uint8Array } + | { type: 'game-message'; readable: Uint8Array }; + +function selectedCardsToBitfield(selectedCards: bigint[], hand: bigint[]): bigint { + return hand.reduce( + (bitfield, cardId, index) => + selectedCards.includes(cardId) ? bitfield | (1n << BigInt(index)) : bitfield, + 0n, + ); +} + +export function isCalpokerOutcomeReadable(readable: Uint8Array | number[]): boolean { + try { + const result = Program.deserialize(Uint8Array.from(readable)).toList(); + return result.length === 6 && result[3].toList().length > 0 && result[4].toList().length > 0; + } catch { + return false; + } +} + +function assertCalpokerOutcomeStage(current: CalpokerHandState): void { + if (current.moveNumber < 2n) { + throw new Error( + `Calpoker final readable arrived before local selections were submitted (moveNumber=${current.moveNumber})`, + ); + } + if ( + current.playerHand.length !== 8 || + current.opponentHand.length !== 8 || + current.cardSelections?.length !== 4 || + !current.cardSelections.every((card) => current.playerHand.includes(card)) + ) { + throw new Error('Calpoker final readable arrived without complete local hand selections'); + } +} + +export function calpokerOutcomeFromState( + current: CalpokerHandState, + readable: Uint8Array | number[], + iStarted: boolean, +): CalpokerOutcome { + return new CalpokerOutcome( + iStarted, + selectedCardsToBitfield(current.cardSelections ?? [], current.playerHand), + iStarted ? current.opponentHand : current.playerHand, + iStarted ? current.playerHand : current.opponentHand, + readable, + ); +} + +function calpokerOutcomeShape(outcome: CalpokerOutcome): CalpokerOutcomeShape { + return { + my_win_outcome: outcome.my_win_outcome, + my_cards: outcome.my_cards, + their_cards: outcome.their_cards, + my_final_hand: outcome.my_final_hand, + their_final_hand: outcome.their_final_hand, + my_used_cards: outcome.my_used_cards, + their_used_cards: outcome.their_used_cards, + my_hand_value: outcome.my_hand_value, + their_hand_value: outcome.their_hand_value, + }; +} + +export function reduceCalpokerFeatureState( + current: CalpokerHandState, + event: CalpokerFeatureEvent, +): CalpokerHandState { + if (event.type === 'game-message') { + return { ...current, ...cardsFromReadable(event.readable, current.iStarted) }; + } + if (isCalpokerOutcomeReadable(event.readable)) { + assertCalpokerOutcomeStage(current); + const outcome = calpokerOutcomeFromState(current, event.readable, current.iStarted); + const display = projectCalpokerFinalDisplay(outcome); + return { + ...current, + playerHand: display.playerCards, + opponentHand: display.opponentCards, + cardSelections: [], + isPlayerTurn: true, + outcome: calpokerOutcomeShape(outcome), + displaySnapshot: { + gameState: 'final', + winner: display.winner, + playerBestHandCardIds: display.playerBestHandCardIds, + opponentBestHandCardIds: display.opponentBestHandCardIds, + playerHaloCardIds: display.playerHaloCardIds, + opponentHaloCardIds: display.opponentHaloCardIds, + playerDisplayText: display.playerDisplayText, + opponentDisplayText: display.opponentDisplayText, + }, + }; + } + return { + ...current, + ...(current.moveNumber === 1n && !current.iStarted + ? cardsFromReadable(event.readable, current.iStarted) + : {}), + isPlayerTurn: true, + }; +} + +export function reduceCalpokerDurableState( + current: CalpokerHandState | null, + event: GameInput, +): CalpokerHandState | null { + if (event.type === 'hand-started') { + return current ?? initialState(event.init.canAct, event.init.iStarted); + } + if (!current) return null; + if (event.type === 'hand-ended') return { ...current, isPlayerTurn: false }; + if (event.type === 'move-rejected') { + return { + ...current, + error: { tag: event.tag, message: event.message }, + }; + } + if (event.type === 'opponent-moved' || event.type === 'game-message') { + return reduceCalpokerFeatureState(current, { + type: event.type, + readable: event.readable, + }); + } + return current; +} diff --git a/games/calpoker/ui/settlement.ts b/games/calpoker/ui/settlement.ts new file mode 100644 index 000000000..c9a68d7b3 --- /dev/null +++ b/games/calpoker/ui/settlement.ts @@ -0,0 +1,44 @@ +import { isForfeitOutcome, settlementByUs, type SettlementOutcome } from '../../host'; + +export function calpokerTimeoutBadge( + outcome: SettlementOutcome, + side: 'ours' | 'theirs', + handCompleted = false, +): 'winner' | 'timeout' | 'forfeit' | null { + if (handCompleted && !isForfeitOutcome(outcome)) { + return null; + } + if ( + outcome === 'accept_settlement' || + outcome === 'we_accepted' || + outcome === 'settled_cleanly' || + outcome === 'lost' + ) { + return null; + } + const byUs = settlementByUs(outcome); + if (byUs == null) return null; + if (side === 'ours') { + if (byUs) return isForfeitOutcome(outcome) ? 'forfeit' : 'timeout'; + return 'winner'; + } + if (!byUs) return isForfeitOutcome(outcome) ? 'forfeit' : 'timeout'; + return 'winner'; +} + +export function calpokerSettlementVerb(outcome: SettlementOutcome): string { + if (isForfeitOutcome(outcome)) return 'forfeited'; + if (outcome === 'lost') return 'loses'; + if (outcome === 'attempt_to_move_failed') return 'moved too late'; + if ( + outcome === 'accept_settlement' || + outcome === 'we_accepted' || + outcome === 'settled_cleanly' + ) { + return 'settled'; + } + if (outcome === 'slashed_opponent') return 'slashed opponent'; + if (outcome === 'opponent_slashed_us') return 'was slashed'; + if (outcome === 'opponent_cheated') return 'cheated'; + return 'timed out'; +} diff --git a/games/calpoker/ui/styles.css b/games/calpoker/ui/styles.css new file mode 100644 index 000000000..ce6eaeb39 --- /dev/null +++ b/games/calpoker/ui/styles.css @@ -0,0 +1,52 @@ +.card-face { + container-type: inline-size; + background-color: var(--suit-color); + color: #fff; +} + +.dark .card-face { + background-color: #fff; + color: var(--suit-color); +} + +.card-face.card-dimmed { + background-color: #fff; + color: #b0b0b0; +} + +.dark .card-face.card-dimmed { + background-color: #b0b0b0; + color: #888; +} + +.card-face.card-hidden { + background-color: transparent; + color: transparent; +} + +.hand-reorder-group { + display: flex !important; + flex-wrap: wrap !important; + justify-content: center !important; + gap: 0.5rem !important; + width: 100% !important; +} + +.hand-reorder-group > * { + width: var(--card-w) !important; + flex-shrink: 0 !important; +} + +.animate-move { + animation: moveCard ease-in-out forwards; +} +@keyframes moveCard { + from { + left: var(--start-x); + top: var(--start-y); + } + to { + left: var(--end-x); + top: var(--end-y); + } +} diff --git a/front-end/src/features/calPoker/types/BestHandType.ts b/games/calpoker/ui/types/BestHandType.ts similarity index 100% rename from front-end/src/features/calPoker/types/BestHandType.ts rename to games/calpoker/ui/types/BestHandType.ts diff --git a/front-end/src/features/calPoker/types/CaliforniapokerProps.ts b/games/calpoker/ui/types/CaliforniapokerProps.ts similarity index 82% rename from front-end/src/features/calPoker/types/CaliforniapokerProps.ts rename to games/calpoker/ui/types/CaliforniapokerProps.ts index 512f0a4e3..ff06ec1c3 100644 --- a/front-end/src/features/calPoker/types/CaliforniapokerProps.ts +++ b/games/calpoker/ui/types/CaliforniapokerProps.ts @@ -1,3 +1,6 @@ +import type { GameInteractionMode, SettlementOutcome } from '../../../host'; +import type { CalpokerError } from '../serialize'; + export interface CalpokerOutcomeView { my_win_outcome: 'win' | 'lose' | 'tie'; my_cards: string[]; @@ -36,6 +39,7 @@ export interface CaliforniapokerProps { initialSnapshot?: CalpokerDisplaySnapshotView; myName?: string; opponentName?: string; - terminalOutcome?: import('../../../lib/settlement').SettlementOutcome | null; - interactionMode?: import('../../../lib/gameMount').GameInteractionMode; + terminalOutcome?: SettlementOutcome | null; + interactionMode?: GameInteractionMode; + error?: CalpokerError | null; } diff --git a/front-end/src/features/calPoker/types/CardContentProps.ts b/games/calpoker/ui/types/CardContentProps.ts similarity index 100% rename from front-end/src/features/calPoker/types/CardContentProps.ts rename to games/calpoker/ui/types/CardContentProps.ts diff --git a/front-end/src/features/calPoker/types/CardRenderProps.ts b/games/calpoker/ui/types/CardRenderProps.ts similarity index 100% rename from front-end/src/features/calPoker/types/CardRenderProps.ts rename to games/calpoker/ui/types/CardRenderProps.ts diff --git a/front-end/src/features/calPoker/types/CardValueSuit.ts b/games/calpoker/ui/types/CardValueSuit.ts similarity index 100% rename from front-end/src/features/calPoker/types/CardValueSuit.ts rename to games/calpoker/ui/types/CardValueSuit.ts diff --git a/front-end/src/features/calPoker/types/FormatHandProps.ts b/games/calpoker/ui/types/FormatHandProps.ts similarity index 100% rename from front-end/src/features/calPoker/types/FormatHandProps.ts rename to games/calpoker/ui/types/FormatHandProps.ts diff --git a/front-end/src/features/calPoker/types/HandDisplayProps.ts b/games/calpoker/ui/types/HandDisplayProps.ts similarity index 100% rename from front-end/src/features/calPoker/types/HandDisplayProps.ts rename to games/calpoker/ui/types/HandDisplayProps.ts diff --git a/front-end/src/features/calPoker/types/MovingCardProps.ts b/games/calpoker/ui/types/MovingCardProps.ts similarity index 100% rename from front-end/src/features/calPoker/types/MovingCardProps.ts rename to games/calpoker/ui/types/MovingCardProps.ts diff --git a/front-end/src/features/calPoker/types/cardHelpers.ts b/games/calpoker/ui/types/cardHelpers.ts similarity index 100% rename from front-end/src/features/calPoker/types/cardHelpers.ts rename to games/calpoker/ui/types/cardHelpers.ts diff --git a/front-end/src/features/calPoker/types/index.ts b/games/calpoker/ui/types/index.ts similarity index 100% rename from front-end/src/features/calPoker/types/index.ts rename to games/calpoker/ui/types/index.ts diff --git a/games/calpoker/ui/useCalpokerHand.ts b/games/calpoker/ui/useCalpokerHand.ts new file mode 100644 index 000000000..63bd4d758 --- /dev/null +++ b/games/calpoker/ui/useCalpokerHand.ts @@ -0,0 +1,279 @@ +import { useEffect, useCallback, useRef } from 'react'; +import { Program } from 'clvm-lib'; +import type { CalpokerOutcomeShape } from './outcome'; +import type { GameHandOrigin, GameHandSource, GameTerminalModel } from '../../host'; +import { gameHandState, requireLiveGameHandSource } from '../../host'; +import { + calpokerStateCodec, + type CalpokerDisplaySnapshot, + type CalpokerHandState, +} from './serialize'; + +export type { CalpokerDisplaySnapshot, CalpokerHandState } from './serialize'; + +type LocalGameCommand = + | { type: 'make-move'; readable: Program | null } + | { type: 'accept-settlement' } + | { type: 'cheat'; moverShare: bigint }; + +export interface UseCalpokerHandResult { + playerHand: bigint[]; + opponentHand: bigint[]; + cardSelections: bigint[]; + setCardSelections: (s: bigint[] | ((prev: bigint[]) => bigint[])) => void; + setHandOrder: (playerHand: bigint[], opponentHand?: bigint[]) => void; + moveNumber: bigint; + outcome: CalpokerOutcomeShape | undefined; + error: CalpokerHandState['error']; + terminalOutcome: GameTerminalModel['outcome']; + handleMakeMove: () => void; + handleCheat: () => void; + saveDisplaySnapshot: (snapshot: CalpokerDisplaySnapshot) => void; + initialDisplaySnapshot: CalpokerDisplaySnapshot | undefined; +} + +export function shouldAutoFireCalpokerMove( + handFinished: boolean, + isPlayerTurn: boolean, + moveNumber: bigint, +): boolean { + return !handFinished && isPlayerTurn && (moveNumber === 0n || moveNumber === 2n); +} + +export function shouldRestoreCalpokerSelection( + moveNumber: string, + hasOutcome: boolean, + hasTerminalOutcome: boolean, +): boolean { + return moveNumber === '1' && !hasOutcome && !hasTerminalOutcome; +} + +// At the endgame reveal (currentMove >= 2) exactly one player still owes a +// terminal move: the first mover, whose initial turn is `!iStarted` +// (iStarted === false) — this is "Alice" in CalpokerOutcome terms. She has just +// received the opponent's reveal (step d) and her autofire still needs to play +// step e, so she must NOT mark the hand finished. The responder +// (iStarted === true, "Bob") has received Alice's terminal move; the hand is +// over for him and he must not fire a phantom sixth move, so he finishes here. +export function calpokerResponderFinishesAtReveal(iStarted: boolean): boolean { + return iStarted; +} + +export function useCalpokerHand( + handSource: GameHandSource, + gameId: string, + iStarted: boolean, + terminal: GameTerminalModel, + handOrigin: GameHandOrigin = 'fresh', +): UseCalpokerHandResult { + const interactive = handSource.interactionMode === 'live'; + const handState = calpokerStateCodec.decode(gameHandState(handSource)); + if (!handState) { + throw new Error('California Poker mount requires initialized durable game state'); + } + const handSourceRef = useRef(handSource); + const gameIdRef = useRef(gameId); + const pendingPlayRef = useRef(false); + const restoredRef = useRef(handOrigin === 'restored'); + const autoSubmissionRef = useRef(null); + const suppressInitialOutcomeRef = useRef( + handOrigin !== 'fresh' && + handState.outcome !== undefined && + handState.displaySnapshot?.gameState === 'final', + ); + + handSourceRef.current = handSource; + gameIdRef.current = gameId; + + const currentState = useCallback((): CalpokerHandState => { + const current = calpokerStateCodec.decode(gameHandState(handSourceRef.current)); + if (!current) { + throw new Error('California Poker action requires initialized durable game state'); + } + return current; + }, []); + + const commitState = useCallback( + (update: (current: CalpokerHandState) => CalpokerHandState): void => { + const controller = requireLiveGameHandSource(handSourceRef.current); + controller.dispatch({ type: 'update-local-state', state: update(currentState()) }); + }, + [currentState], + ); + + const commitLocalAction = useCallback( + ( + update: (current: CalpokerHandState) => CalpokerHandState, + command: LocalGameCommand, + ): void => { + const controller = requireLiveGameHandSource(handSourceRef.current); + const next = { ...update(currentState()), error: null }; + controller.dispatch( + command.type === 'make-move' + ? { + type: 'make-move', + gameId: gameIdRef.current, + readable: command.readable, + state: next, + } + : command.type === 'accept-settlement' + ? { type: 'accept-settlement', gameId: gameIdRef.current, state: next } + : { + type: 'cheat', + gameId: gameIdRef.current, + moverShare: command.moverShare, + state: next, + }, + ); + }, + [currentState], + ); + + const submitMove1 = useCallback(() => { + const controller = requireLiveGameHandSource(handSourceRef.current); + if (!controller.isChannelReady()) return; + const gid = gameIdRef.current; + if (!gid) return; + const current = currentState(); + if ((current.cardSelections ?? []).length !== 4) return; + const cards = current.cardSelections ?? []; + commitLocalAction((current) => ({ ...current, moveNumber: 2n, isPlayerTurn: false }), { + type: 'make-move', + readable: Program.fromList(cards.map((c) => Program.fromBigInt(c))), + }); + pendingPlayRef.current = false; + }, [commitLocalAction, currentState]); + + const handleMakeMove = useCallback(() => { + const controller = requireLiveGameHandSource(handSourceRef.current); + if (!controller.isChannelReady()) return; + const gid = gameIdRef.current; + if (!gid) return; + + const current = currentState(); + const handFinished = + terminal.outcome !== null || + (current.outcome !== undefined && calpokerResponderFinishesAtReveal(iStarted)); + if (handFinished) return; + const currentMove = current.moveNumber; + + if (currentMove === 0n) { + commitLocalAction((current) => ({ ...current, moveNumber: 1n, isPlayerTurn: false }), { + type: 'make-move', + readable: null, + }); + } else if (currentMove === 1n) { + if ((current.cardSelections ?? []).length !== 4) return; + if (current.isPlayerTurn) { + submitMove1(); + } else { + pendingPlayRef.current = true; + } + } else if (currentMove === 2n) { + commitLocalAction((current) => ({ ...current, moveNumber: 3n, isPlayerTurn: false }), { + type: 'make-move', + readable: null, + }); + } + }, [commitLocalAction, currentState, iStarted, submitMove1, terminal.outcome]); + + // Autofire moves 0 and 2; auto-submit queued move 1 + useEffect(() => { + if (!interactive) return; + if (restoredRef.current) { + restoredRef.current = false; + return; + } + const handFinished = + terminal.outcome !== null || + (handState.outcome !== undefined && calpokerResponderFinishesAtReveal(iStarted)); + if (handFinished || !handState.isPlayerTurn) return; + const controller = requireLiveGameHandSource(handSourceRef.current); + if (!controller.isChannelReady() || !gameId) return; + const m = handState.moveNumber; + const submissionKey = `${gameId}:${m}`; + if (autoSubmissionRef.current === submissionKey) return; + if (shouldAutoFireCalpokerMove(handFinished, handState.isPlayerTurn, m)) { + autoSubmissionRef.current = submissionKey; + handleMakeMove(); + } else if (m === 1n && pendingPlayRef.current) { + autoSubmissionRef.current = submissionKey; + submitMove1(); + } + }, [ + gameId, + handSource, + handState.isPlayerTurn, + handState.moveNumber, + handState.outcome, + iStarted, + interactive, + handleMakeMove, + submitMove1, + terminal.outcome, + ]); + + const handleCheat = useCallback(() => { + requireLiveGameHandSource(handSourceRef.current); + const gid = gameIdRef.current; + if (!gid) return; + // A cheat is still a local move candidate, so it uses the same game-state + // transition as a normal move while the host handles protocol execution. + commitLocalAction((current) => ({ ...current, isPlayerTurn: false }), { + type: 'cheat', + moverShare: 0n, + }); + }, [commitLocalAction]); + + const setCardSelections = useCallback( + (selectionsOrFn: bigint[] | ((prev: bigint[]) => bigint[])) => { + if (typeof selectionsOrFn === 'function') { + commitState((current) => ({ + ...current, + cardSelections: selectionsOrFn(current.cardSelections ?? []), + })); + } else { + commitState((current) => ({ ...current, cardSelections: selectionsOrFn })); + } + }, + [commitState], + ); + + const setHandOrder = useCallback( + (nextPlayerHand: bigint[], nextOpponentHand?: bigint[]) => { + commitState((current) => ({ + ...current, + playerHand: nextPlayerHand, + opponentHand: nextOpponentHand ?? current.opponentHand, + cardSelections: (current.cardSelections ?? []).filter((card) => + nextPlayerHand.includes(card), + ), + })); + }, + [commitState], + ); + + const saveDisplaySnapshot = useCallback( + (snapshot: CalpokerDisplaySnapshot) => { + requireLiveGameHandSource(handSourceRef.current); + commitState((current) => ({ ...current, displaySnapshot: snapshot })); + }, + [commitState], + ); + + return { + playerHand: handState.playerHand, + opponentHand: handState.opponentHand, + cardSelections: handState.cardSelections ?? [], + setCardSelections, + setHandOrder, + moveNumber: handState.moveNumber, + outcome: suppressInitialOutcomeRef.current ? undefined : handState.outcome, + error: handState.error, + terminalOutcome: terminal.outcome, + handleMakeMove, + handleCheat, + saveDisplaySnapshot, + initialDisplaySnapshot: handState.displaySnapshot, + }; +} diff --git a/clsp/test/debug_game.clsp b/games/debug/clsp/factory.clsp similarity index 93% rename from clsp/test/debug_game.clsp rename to games/debug/clsp/factory.clsp index 59197d46d..ddf45f08b 100644 --- a/clsp/test/debug_game.clsp +++ b/games/debug/clsp/factory.clsp @@ -27,20 +27,18 @@ ) ) -(defmac CURRY_PACK () (q @ curry-args (COUNT SELF_HASH SELF_PROG MOVER0 WAITER0))) +(defmac CURRY_PACK () (q @ curry-args (COUNT SELF_HASH SELF_PROG))) (defun curry-args-incr ((CURRY_PACK)) (c (+ 1 COUNT) (r curry-args)) ) ;; Compute shatree(curry-args) using SELF_HASH in place of shatree(SELF_PROG) -;; curry-args = (COUNT SELF_HASH SELF_PROG MOVER0 WAITER0) +;; curry-args = (COUNT SELF_HASH SELF_PROG) (defun curry-args-hash ((CURRY_PACK)) (sha256 2 (sha256 1 COUNT) (sha256 2 (sha256 1 SELF_HASH) - (sha256 2 SELF_HASH - (sha256 2 (sha256 1 MOVER0) - (sha256 2 (sha256 1 WAITER0) (sha256 1 ())))))) + (sha256 2 SELF_HASH (sha256 1 ())))) ) (defun validator-hash ((CURRY_PACK)) @@ -51,8 +49,8 @@ ) ) -(defun current-waiter-pubkey ((CURRY_PACK)) - (if (logand COUNT 1) MOVER0 WAITER0) +(defun current-waiter-pubkey ((CURRY_PACK) waiter-pubkey) + waiter-pubkey ) ;; Every valid move encapsulates the entire set of data that the validator will be exposed @@ -77,7 +75,7 @@ move-counter-data (concat ;; Hashes first - (if WAITER_PUBKEY WAITER_PUBKEY (current-waiter-pubkey curry-args)) + (if WAITER_PUBKEY WAITER_PUBKEY (current-waiter-pubkey curry-args WAITER_PUBKEY)) MOVER_PUBKEY MOD_HASH INFOHASH_B @@ -107,12 +105,6 @@ ;; check previous validation info hash (print (list "Previous infohash calculation: " (sha256 pv_hash (shatree state))) 0) (print "did we have the right previous validation info hash" (not (= INFOHASH_B (sha256 pv_hash (shatree state))))) - ;; - ;; the step we're on indicates mover0 or waiter0 as mover - (print "did we send mover and waiter pubkey in the right order" (if (logand COUNT 1) - (not (= MOVER_PUBKEY WAITER0)) - (not (= MOVER_PUBKEY MOVER0)) - )) ;; if mover and waiter are the same then things are broken (print "ensure we didn't just send the same pubkey for mover and waiter" (= MOVER_PUBKEY WAITER_PUBKEY)) ;; the evidence is the slash diff --git a/src/test_support/debug_game.rs b/games/debug/rust/mod.rs similarity index 96% rename from src/test_support/debug_game.rs rename to games/debug/rust/mod.rs index 6e58b66bd..e7e908663 100644 --- a/src/test_support/debug_game.rs +++ b/games/debug/rust/mod.rs @@ -23,6 +23,7 @@ use crate::common::types::{ atom_from_clvm, chia_dialect, AllocEncoder, Amount, Error, GameID, Hash, IntoErr, Node, Program, ProgramRef, PublicKey, PuzzleHash, Sha256tree, Timeout, }; +use crate::session_phases::types::GameFactory; use crate::referee::types::{ canonical_atom_from_usize, GameMoveDetails, GameMoveStateInfo, ValidationInfoHash, }; @@ -47,7 +48,7 @@ impl DebugGameCurry { mover_pk: &PublicKey, waiter_pk: &PublicKey, ) -> Result { - let raw_program = read_hex_puzzle(allocator, "clsp/test/debug_game.hex")?; + let raw_program = read_hex_puzzle(allocator, "games/debug/clsp/factory.hex")?; let prog_hash = raw_program.sha256tree(allocator); Ok(DebugGameCurry { count: 0, @@ -66,18 +67,44 @@ where fn to_clvm(&self, encoder: &mut E) -> Result<::Node, ToClvmError> { ( self.count, - ( - self.self_hash.clone(), - ( - self.self_prog.clone(), - (self.mover0.clone(), (self.waiter0.clone(), ())), - ), - ), + (self.self_hash.clone(), (self.self_prog.clone(), ())), ) .to_clvm(encoder) } } +pub const FACTORY_HEX: &str = "games/debug/clsp/factory.hex"; + +pub fn prepared_factory(allocator: &mut AllocEncoder) -> Result { + let raw_program = read_hex_puzzle(allocator, FACTORY_HEX)?; + let node = CurriedProgram { + program: raw_program, + args: clvm_curried_args!("factory", ()), + } + .to_clvm(allocator) + .into_gen()?; + let program = Program::from_nodeptr(allocator, node)?; + Ok(GameFactory { + program: Some(program.into()), + }) +} + +/// Canonical probe: 1-mojo contributions, sender goes first, dummy keys. +pub fn probe_parameters(allocator: &mut AllocEncoder) -> Result { + let args = DebugGameCurry::new( + allocator, + &PublicKey::default(), + &PublicKey::default(), + )?; + let node = (1u64, (1u64, (true, (args, ())))) + .to_clvm(allocator) + .into_gen()?; + Program::from_nodeptr(allocator, node) +} + +#[cfg(test)] +pub mod tests; + pub struct DebugGameMoveInfo { pub ui_move: ReadableMove, pub slash: Option>, @@ -611,6 +638,7 @@ pub fn make_debug_games_with_contributions( ) } +#[cfg(test)] pub fn test_debug_game_factory() { let mut allocator = AllocEncoder::new(); let rng_seed: [u8; 32] = [0; 32]; @@ -774,6 +802,7 @@ impl ExhaustiveMoveInputs { } } +#[cfg(test)] pub fn test_debug_game_validation_move() { let mut allocator = AllocEncoder::new(); let rng_seed: [u8; 32] = [0; 32]; @@ -802,6 +831,7 @@ pub fn test_debug_game_validation_move() { .expect("ok"); } +#[cfg(test)] pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { vec![ ("test_debug_game_factory", &test_debug_game_factory), diff --git a/games/debug/rust/tests/mod.rs b/games/debug/rust/tests/mod.rs new file mode 100644 index 000000000..e05209df9 --- /dev/null +++ b/games/debug/rust/tests/mod.rs @@ -0,0 +1,3 @@ +pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { + super::test_funs() +} diff --git a/games/host/index.ts b/games/host/index.ts new file mode 100644 index 000000000..f1f228fc4 --- /dev/null +++ b/games/host/index.ts @@ -0,0 +1,605 @@ +import { createElement, type ComponentType, type ReactElement } from 'react'; +import { Program } from 'clvm-lib'; + +/** Compact settlement outcome ids (snake_case; match Rust `SettlementOutcome`). */ +export type SettlementOutcome = + | 'accept_settlement' + | 'settled_cleanly' + | 'opponent_timed_out' + | 'forfeited_skipped_reveal' + | 'lost' + | 'forfeited_we_accepted' + | 'we_accepted' + | 'attempt_to_move_failed' + | 'timed_out_waiting_for_our_move' + | 'slashed_opponent' + | 'opponent_slashed_us' + | 'opponent_cheated'; + +const ALL_OUTCOMES: ReadonlySet = new Set([ + 'accept_settlement', + 'settled_cleanly', + 'opponent_timed_out', + 'forfeited_skipped_reveal', + 'lost', + 'forfeited_we_accepted', + 'we_accepted', + 'attempt_to_move_failed', + 'timed_out_waiting_for_our_move', + 'slashed_opponent', + 'opponent_slashed_us', + 'opponent_cheated', +]); + +export const SETTLEMENT_OUTCOME_LABELS: Record = { + accept_settlement: 'Accepted', + settled_cleanly: 'Settled cleanly', + opponent_timed_out: 'Opponent timed out', + forfeited_skipped_reveal: 'Forfeited', + lost: 'Lost', + forfeited_we_accepted: 'Forfeited', + we_accepted: 'Accepted', + attempt_to_move_failed: 'Attempt to move failed', + timed_out_waiting_for_our_move: 'Timed out waiting for our move', + slashed_opponent: 'Slashed opponent', + opponent_slashed_us: 'Opponent slashed us', + opponent_cheated: 'Opponent cheated', +}; + +export function isSettlementOutcome(value: unknown): value is SettlementOutcome { + return typeof value === 'string' && ALL_OUTCOMES.has(value); +} + +export function settlementLabel(outcome: SettlementOutcome): string { + return SETTLEMENT_OUTCOME_LABELS[outcome]; +} + +export function isForfeitOutcome(outcome: SettlementOutcome): boolean { + return outcome === 'forfeited_skipped_reveal' || outcome === 'forfeited_we_accepted'; +} + +export function isErrorSettlementOutcome(outcome: SettlementOutcome): boolean { + return ( + isForfeitOutcome(outcome) || + outcome === 'timed_out_waiting_for_our_move' || + outcome === 'attempt_to_move_failed' || + outcome === 'opponent_slashed_us' || + outcome === 'opponent_cheated' + ); +} + +export function settlementByUs(outcome: SettlementOutcome): boolean | null { + switch (outcome) { + case 'accept_settlement': + case 'we_accepted': + case 'forfeited_skipped_reveal': + case 'forfeited_we_accepted': + case 'lost': + case 'timed_out_waiting_for_our_move': + case 'attempt_to_move_failed': + case 'slashed_opponent': + return true; + case 'opponent_timed_out': + case 'opponent_slashed_us': + case 'opponent_cheated': + return false; + case 'settled_cleanly': + return null; + } +} + +export function parseSettlementShare(value: unknown): string | null { + if (value == null) return null; + if ( + typeof value === 'object' && + value !== null && + 'Amount' in (value as Record) + ) { + return String((value as Record).Amount); + } + if (typeof value === 'object' && value !== null && 'amt' in (value as Record)) { + return String((value as Record).amt); + } + return String(value); +} + +export type GameTerminalType = + | 'none' + | 'settled' + | 'insufficient-balance' + | 'ended-cancelled' + | 'game-error'; + +export interface GameTerminalModel { + type: GameTerminalType; + outcome: SettlementOutcome | null; + label: string | null; + myReward: string | null; + rewardCoinHex: string | null; +} + +export const EMPTY_GAME_TERMINAL_MODEL: GameTerminalModel = { + type: 'none', + outcome: null, + label: null, + myReward: null, + rewardCoinHex: null, +}; + +export interface GameHostText { + formatMojos(mojos: bigint): string; +} + +export interface HandProposalBase { + myContribution: bigint; + theirContribution: bigint; + gameTimeout: bigint; +} + +export type RegisteredGameType = string; + +export type HandProposal = HandProposalBase & { + gameType: RegisteredGameType; +}; + +export type HandWinOutcome = { my_win_outcome: 'win' | 'lose' | 'tie' }; + +export type ProposalGroupOrigin = 'local' | 'peer'; + +export interface PersistedGameState { + gameType: string; + version: bigint; + state: T; +} + +export interface GameStateCodec { + gameType: string; + readonly version: bigint; + readonly canRemountFinished: boolean; + isState(value: unknown): value is T; + gameIds(state: T): readonly string[]; + encode(state: T): PersistedGameState; + decode(value: unknown): T | null; +} + +/** Untrusted factory-parameter blob → game-owned parameter record. */ +export interface FactoryParameterCodec { + decode(value: unknown): TParams | null; + encode(params: TParams): Program; +} + +export function readClvmProgram(value: unknown): Program | null { + if (!(value instanceof Uint8Array)) return null; + try { + const program = Program.deserialize(value); + const canonical = program.serialize(); + if ( + canonical.length !== value.length || + canonical.some((byte, index) => byte !== value[index]) + ) { + return null; + } + return program; + } catch { + return null; + } +} + +export function readClvmAtom(program: Program): bigint | null { + try { + return program.toBigInt(); + } catch { + return null; + } +} + +export function readClvmFlag(program: Program): boolean | null { + const value = readClvmAtom(program); + if (value === 0n) return false; + if (value === 1n) return true; + return null; +} + +export function readClvmList(program: Program, length: number): readonly Program[] | null { + if (!program.isCons) return null; + try { + const items = program.toList(true); + return items.length === length ? items : null; + } catch { + return null; + } +} + +export function defineGameStateCodec(definition: { + gameType: string; + version: bigint; + canRemountFinished: boolean; + isState(value: unknown): value is T; + gameIds?: (state: T) => readonly string[]; +}): GameStateCodec { + const codec: GameStateCodec = { + gameType: definition.gameType, + version: definition.version, + canRemountFinished: definition.canRemountFinished, + isState: definition.isState, + gameIds: definition.gameIds ?? (() => []), + encode: (state) => ({ gameType: codec.gameType, version: codec.version, state }), + decode: (value) => { + if (typeof value !== 'object' || value === null) return null; + const persisted = value as Partial; + return persisted.gameType === codec.gameType && + persisted.version === codec.version && + codec.isState(persisted.state) + ? persisted.state + : null; + }, + }; + return codec; +} + +export type GameIntent = + | { type: 'update-local-state'; state: TState } + | { type: 'make-move'; gameId: string; readable: Program | null; state: TState } + | { type: 'accept-settlement'; gameId: string; state: TState } + | { type: 'cheat'; gameId: string; moverShare: bigint; state: TState }; + +export interface GameHandInitialization { + id: string; + gameIds: readonly string[]; + iStarted: boolean; + canAct: boolean; + origin: ProposalGroupOrigin; + handProposal: HandProposal; +} + +export type GameInput = + | { type: 'hand-started'; init: TInit } + | { + type: 'opponent-moved'; + gameId: string; + readable: Uint8Array; + moverShare: string; + } + | { type: 'game-message'; gameId: string; readable: Uint8Array } + | { type: 'move-rejected'; gameId: string; tag: string; message: string } + | { type: 'hand-ended'; gameId: string; terminal: GameTerminalModel }; + +export type ComposeDraftValue = Record; +export type GameComposeDrafts = Record; +export type SavedHandProposalExtras = Readonly>; +export type StateUpdate = T | ((current: T) => T); +export type HandProposalFor = HandProposal & { gameType: T }; + +export interface HandProposalFormProps { + draft: TDraft; + disabled: boolean; + maxPerHandMojos: bigint | null; + onChange: (update: Partial) => void; + onSubmit: () => void; +} + +export interface HandProposalDecodeContext { + readonly origin: ProposalGroupOrigin; + readonly iStarted: boolean; + readonly expectedSenderGoesFirst: boolean; +} + +export function reduceGameStateSnapshot(current: T, update: StateUpdate): T { + return typeof update === 'function' ? (update as (value: T) => T)(current) : update; +} + +export function equalHandProposalBase(a: HandProposalBase, b: HandProposalBase): boolean { + return ( + a.myContribution === b.myContribution && + a.theirContribution === b.theirContribution && + a.gameTimeout === b.gameTimeout + ); +} + +export interface LiveGameProtocolPort { + isChannelReady(): boolean; +} + +export interface LiveGamePort extends LiveGameProtocolPort { + dispatch(intent: GameIntent): void; +} + +export type GameInteractionMode = 'live' | 'terminal'; +export type GameHandOrigin = 'fresh' | 'restored' | 'terminal'; + +export type GameHandSource = + | { + readonly interactionMode: 'live'; + readonly handState: Readonly | null; + readonly port: LiveGamePort; + } + | { + readonly interactionMode: 'terminal'; + readonly handState: Readonly | null; + }; + +export function terminalGameHandSource( + handState: Readonly | null, +): Extract { + const source = { interactionMode: 'terminal' } as Extract< + GameHandSource, + { interactionMode: 'terminal' } + >; + Object.defineProperty(source, 'handState', { + value: handState, + enumerable: false, + writable: false, + configurable: false, + }); + return Object.freeze(source); +} + +export function gameHandState(source: GameHandSource): Readonly | null { + return source.handState; +} + +export function requireLiveGameHandSource(source: GameHandSource): LiveGamePort { + if (source.interactionMode !== 'live') { + throw new Error('Protocol commands require a live game hand source'); + } + return source.port; +} + +export function liveGameHandOrigin( + restoredHandKey: number | null, + currentHandKey: number, +): Exclude { + return restoredHandKey === currentHandKey ? 'restored' : 'fresh'; +} + +export interface GameMountNames { + myName?: string; + opponentName?: string; +} + +export interface FrozenGameMountOptions extends GameMountNames { + iStarted: boolean; +} + +interface GameMountViewBase extends GameMountNames { + handState: Readonly | null; + handOrigin: GameHandOrigin; + lastDisplayedId: string | null; + activeIds: readonly string[]; + currentHandIds: readonly string[]; + canActById: Readonly>; + iStarted: boolean; + playerNumber: number; + instances: Readonly>; +} + +export type GameMountView = + | (GameMountViewBase & { + frozen: false; + port: LiveGamePort; + appendGameLog: (line: string) => void; + }) + | (GameMountViewBase & { frozen: true }); + +export function gameHandSourceFromMountView(view: GameMountView): GameHandSource { + return view.frozen + ? terminalGameHandSource(view.handState) + : { interactionMode: 'live', handState: view.handState, port: view.port }; +} + +export interface GameMountRegistration { + render(view: GameMountView): ReactElement; +} + +export interface GameFeatureRegistration< + TState, + TFeatureState = TState, + TDraft = ComposeDraftValue, + TParams = unknown, +> { + gameType: string; + readonly displayName: string; + readonly stateCodec: GameStateCodec; + readonly factoryParameters: FactoryParameterCodec; + describeHandProposal(handProposal: HandProposal, text: GameHostText): string; + readonly handMembershipDescription: string; + validateHandMembership(gameIds: readonly string[], state: TState | null): boolean; + decodeFeatureState(value: unknown): TFeatureState | null; + selectOutcome(state: TState, gameId: string): HandWinOutcome | null; + readonly lifecycle: { + proposalSenderGoesFirst(iStarted: boolean): boolean; + }; + readonly draft: { + default(perGameAmount: bigint): TDraft; + fromHandProposal(handProposal: HandProposal): TDraft; + update(current: TDraft, update: Partial): TDraft; + toHandProposal(draft: TDraft, gameTimeout: bigint): HandProposal | null; + }; + toFactoryParameters(handProposal: HandProposal, iStarted: boolean): TParams; + decodeHandProposal( + base: HandProposalBase, + params: TParams, + context: HandProposalDecodeContext, + ): HandProposal | null; + validateHandProposal(handProposal: HandProposal): boolean; + handProposalsEqual(a: HandProposal, b: HandProposal): boolean; + persistence: { + encodeExtras(handProposal: HandProposal): SavedHandProposalExtras; + decodeExtras(base: HandProposalBase, extras: SavedHandProposalExtras): HandProposal | null; + }; + readonly durableState: { + initialize( + current: TState | null, + input: Extract, + ): TState; + reduceInput( + current: TState, + input: Exclude, + ): TState; + applyFeatureState(current: TState, gameId: string, state: TFeatureState): TState; + }; +} + +export interface RegisteredGamePackage { + readonly gameType: string; + readonly displayName: string; + readonly stateCodec: GameStateCodec; + describeHandProposal(handProposal: HandProposal, text: GameHostText): string; + readonly handMembershipDescription: string; + validateHandMembership(gameIds: readonly string[], state: unknown | null): boolean; + decodeFeatureState(value: unknown): unknown | null; + selectOutcome(state: unknown, gameId: string): HandWinOutcome | null; + readonly lifecycle: { + proposalSenderGoesFirst(iStarted: boolean): boolean; + }; + readonly draft: { + default(perGameAmount: bigint): ComposeDraftValue; + fromHandProposal(handProposal: HandProposal): ComposeDraftValue; + update(current: ComposeDraftValue, update: Partial): ComposeDraftValue; + toHandProposal(draft: ComposeDraftValue, gameTimeout: bigint): HandProposal | null; + }; + encodeFactoryParameters(handProposal: HandProposal, iStarted: boolean): Program; + decodeHandProposal( + base: HandProposalBase, + parameterState: unknown, + context: HandProposalDecodeContext, + ): HandProposal | null; + validateHandProposal(handProposal: HandProposal): boolean; + handProposalsEqual(a: HandProposal, b: HandProposal): boolean; + readonly persistence: { + encodeExtras(handProposal: HandProposal): SavedHandProposalExtras; + decodeExtras(base: HandProposalBase, extras: SavedHandProposalExtras): HandProposal | null; + }; + readonly durableState: { + initialize( + current: unknown | null, + input: Extract, + ): unknown; + reduceInput( + current: unknown, + input: Exclude, + ): unknown; + applyFeatureState(current: unknown, gameId: string, state: unknown): unknown; + }; + render(view: GameMountView): ReactElement; + renderHandProposalForm(props: HandProposalFormProps): ReactElement; +} + +export function defineGamePackage< + TState, + TFeatureState, + TDraft extends ComposeDraftValue, + TParams, +>( + feature: GameFeatureRegistration, + HandProposalForm: ComponentType>, + mount: GameMountRegistration, +): RegisteredGamePackage { + const requireState = (value: unknown): TState => { + if (!feature.stateCodec.isState(value)) { + throw new Error(`Invalid internal ${feature.gameType} state`); + } + return value; + }; + const stateCodec: GameStateCodec = { + ...feature.stateCodec, + gameIds: (state) => feature.stateCodec.gameIds(requireState(state)), + encode: (state) => feature.stateCodec.encode(requireState(state)), + }; + return { + ...feature, + stateCodec, + validateHandMembership: (gameIds, state) => + state === null + ? feature.validateHandMembership(gameIds, null) + : feature.validateHandMembership(gameIds, requireState(state)), + selectOutcome: (state, gameId) => feature.selectOutcome(requireState(state), gameId), + draft: { + default: feature.draft.default, + fromHandProposal: feature.draft.fromHandProposal, + update: (current, update) => + feature.draft.update(current as TDraft, update as Partial), + toHandProposal: (draft, gameTimeout) => + feature.draft.toHandProposal(draft as TDraft, gameTimeout), + }, + encodeFactoryParameters: (handProposal, iStarted) => + feature.factoryParameters.encode(feature.toFactoryParameters(handProposal, iStarted)), + decodeHandProposal: (base, parameterState, context) => { + const params = feature.factoryParameters.decode(parameterState); + return params === null ? null : feature.decodeHandProposal(base, params, context); + }, + persistence: feature.persistence, + durableState: { + initialize: (current, input) => + feature.durableState.initialize(current === null ? null : requireState(current), input), + reduceInput: (current, input) => + feature.durableState.reduceInput(requireState(current), input), + applyFeatureState: (current, gameId, state) => { + const featureState = feature.decodeFeatureState(state); + if (featureState === null) { + throw new Error(`Invalid internal ${feature.gameType} feature state`); + } + return feature.durableState.applyFeatureState( + requireState(current), + gameId, + featureState, + ); + }, + }, + render: mount.render, + renderHandProposalForm: (props) => + createElement(HandProposalForm, { + ...props, + draft: props.draft as unknown as TDraft, + }), + }; +} + +export interface CurrencyLabels { + xch: string; + chia: string; + mojo: string; + mojos: string; + MOJO: string; +} + +export const DEFAULT_CURRENCY_LABELS: CurrencyLabels = { + xch: 'XCH', + chia: 'chia', + mojo: 'mojo', + mojos: 'mojos', + MOJO: 'MOJO', +}; + +export function formatAmountWithLabels(mojos: bigint, labels: CurrencyLabels): string { + if (mojos < 1_000_000n) { + return `${mojos} ${labels.MOJO}`; + } + const TRILLION = 1_000_000_000_000n; + const whole = mojos / TRILLION; + const frac = mojos % TRILLION; + if (frac === 0n) return `${whole} ${labels.xch}`; + const fracStr = frac.toString().padStart(12, '0').replace(/0+$/, ''); + return `${whole}.${fracStr} ${labels.xch}`; +} + +export function formatMojosWithLabels(mojos: bigint, labels: CurrencyLabels): string { + const TRILLION = 1_000_000_000_000n; + const absMojos = mojos < 0n ? -mojos : mojos; + if (absMojos >= 100_000_000n) { + const sign = mojos < 0n ? '-' : ''; + const whole = absMojos / TRILLION; + const frac = absMojos % TRILLION; + const fracStr = frac.toString().padStart(12, '0').slice(0, 4); + return `${sign}${whole.toLocaleString()}.${fracStr} ${labels.xch}`; + } + return `${mojos.toLocaleString()} ${labels.mojos}`; +} + +export function defaultFormatAmount(mojos: bigint): string { + return formatAmountWithLabels(mojos, DEFAULT_CURRENCY_LABELS); +} + +export function defaultFormatMojos(mojos: bigint): string { + return formatMojosWithLabels(mojos, DEFAULT_CURRENCY_LABELS); +} diff --git a/front-end/src/components/AmountInput.tsx b/games/host/ui.tsx similarity index 71% rename from front-end/src/components/AmountInput.tsx rename to games/host/ui.tsx index bb1b81b05..581ad63a9 100644 --- a/front-end/src/components/AmountInput.tsx +++ b/games/host/ui.tsx @@ -1,5 +1,74 @@ -import { useState, useCallback, useRef, useEffect } from 'react'; -import { getCurrencyLabels } from '../constants/currency'; +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from 'react'; +import { + DEFAULT_CURRENCY_LABELS, + defaultFormatAmount, + defaultFormatMojos, + type CurrencyLabels, +} from './index'; + +export interface GameHostServices { + formatAmount(mojos: bigint): string; + formatMojos(mojos: bigint): string; + currencyLabels: CurrencyLabels; +} + +const DEFAULT_HOST_SERVICES: GameHostServices = { + formatAmount: defaultFormatAmount, + formatMojos: defaultFormatMojos, + currencyLabels: DEFAULT_CURRENCY_LABELS, +}; + +const GameHostContext = createContext(DEFAULT_HOST_SERVICES); + +export function GameHostProvider({ + services, + children, +}: { + services: GameHostServices; + children: ReactNode; +}) { + return {children}; +} + +export function useGameHost(): GameHostServices { + return useContext(GameHostContext); +} + +export function useCheatKeys(handleCheat: () => void, enabled = true): void { + const cheatBufRef = useRef(''); + useEffect(() => { + if (!enabled) return; + const CHEAT_SEQ = 'cheat^'; + const handleKeyDown = (e: globalThis.KeyboardEvent) => { + if (e.altKey || e.ctrlKey || e.metaKey) return; + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + if (e.key.length !== 1) return; + + const cheatBuf = cheatBufRef.current + e.key; + if (CHEAT_SEQ.startsWith(cheatBuf)) { + cheatBufRef.current = cheatBuf; + if (cheatBuf === CHEAT_SEQ) { + cheatBufRef.current = ''; + handleCheat(); + } + } else { + cheatBufRef.current = CHEAT_SEQ.startsWith(e.key) ? e.key : ''; + } + + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [enabled, handleCheat]); +} function mojosToXchStr(mojos: bigint): string { const s = mojos.toString().padStart(13, '0'); @@ -37,7 +106,7 @@ function parseXchInput(raw: string): bigint | null { type AmountUnit = 'mojo' | 'xch'; -interface AmountInputProps { +export interface AmountInputProps { valueMojos: bigint; onChange: (mojos: bigint) => void; maxMojos?: bigint | null; @@ -45,7 +114,7 @@ interface AmountInputProps { disabled?: boolean; label?: string; exceedsLabel?: string; - onKeyDown?: (e: React.KeyboardEvent) => void; + onKeyDown?: (e: KeyboardEvent) => void; } export function AmountInput({ @@ -58,7 +127,7 @@ export function AmountInput({ exceedsLabel = 'Exceeds available balance.', onKeyDown, }: AmountInputProps) { - const labels = getCurrencyLabels(); + const { currencyLabels: labels } = useGameHost(); const [unit, setUnit] = useState('mojo'); const [rawInput, setRawInput] = useState(() => valueMojos.toString()); const lastExternalMojos = useRef(valueMojos); diff --git a/games/krunk/clsp/factory.clsp b/games/krunk/clsp/factory.clsp new file mode 100644 index 000000000..8ac7c04ca --- /dev/null +++ b/games/krunk/clsp/factory.clsp @@ -0,0 +1,5 @@ +(include *standard-cl-23*) + +(import games.krunk.clsp.krunk_generate exposing krunk_factory) + +(export krunk_factory) diff --git a/clsp/games/krunk/krunk_dict_tree.clinc b/games/krunk/clsp/krunk_dict_tree.clinc similarity index 100% rename from clsp/games/krunk/krunk_dict_tree.clinc rename to games/krunk/clsp/krunk_dict_tree.clinc diff --git a/clsp/games/krunk/krunk_generate.clinc b/games/krunk/clsp/krunk_generate.clinc similarity index 92% rename from clsp/games/krunk/krunk_generate.clinc rename to games/krunk/clsp/krunk_generate.clinc index 70d979ac2..ddcb720e4 100644 --- a/clsp/games/krunk/krunk_generate.clinc +++ b/games/krunk/clsp/krunk_generate.clinc @@ -1,13 +1,13 @@ (include *standard-cl-23*) -(import games.krunk.krunk_helpers exposing +(import games.krunk.clsp.krunk_helpers exposing expand_clue krunk_should_reveal krunk_reveal_move krunk_reveal_mover_share ) -(import games.krunk.krunk_dict_tree exposing dict_lookup) -(import games.krunk.onchain.krunk_make_clue exposing make_clue) +(import games.krunk.clsp.krunk_dict_tree exposing dict_lookup) +(import games.krunk.clsp.onchain.krunk_make_clue exposing make_clue) (import std.li) (import std.curry) (import std.assert) @@ -20,9 +20,9 @@ ; The on-chain initial state is (DICT_PUBKEY base_unit); the full tree stays ; off-chain. -(import games.krunk.onchain.commit exposing (program as val_commit) (program_hash as commit_hash)) -(import games.krunk.onchain.guess exposing (program as val_guess) (program_hash as guess_hash)) -(import games.krunk.onchain.clue exposing (program as val_clue) (program_hash as clue_hash)) +(import games.krunk.clsp.onchain.commit exposing (program as val_commit) (program_hash as commit_hash)) +(import games.krunk.clsp.onchain.guess exposing (program as val_guess) (program_hash as guess_hash)) +(import games.krunk.clsp.onchain.clue exposing (program as val_clue) (program_hash as clue_hash)) (defconstant MIN_WORD 0x8000000000) (defconstant MAX_WORD 0x7fffffffff) diff --git a/clsp/games/krunk/krunk_helpers.clinc b/games/krunk/clsp/krunk_helpers.clinc similarity index 100% rename from clsp/games/krunk/krunk_helpers.clinc rename to games/krunk/clsp/krunk_helpers.clinc diff --git a/clsp/games/krunk/krunk_signed_dict_tree.dat b/games/krunk/clsp/krunk_signed_dict_tree.dat similarity index 100% rename from clsp/games/krunk/krunk_signed_dict_tree.dat rename to games/krunk/clsp/krunk_signed_dict_tree.dat diff --git a/clsp/games/krunk/krunkwords.txt b/games/krunk/clsp/krunkwords.txt similarity index 100% rename from clsp/games/krunk/krunkwords.txt rename to games/krunk/clsp/krunkwords.txt diff --git a/clsp/games/krunk/onchain/clue.clsp b/games/krunk/clsp/onchain/clue.clsp similarity index 93% rename from clsp/games/krunk/onchain/clue.clsp rename to games/krunk/clsp/onchain/clue.clsp index fd00ec87a..b6828fcf1 100644 --- a/clsp/games/krunk/onchain/clue.clsp +++ b/games/krunk/clsp/onchain/clue.clsp @@ -1,7 +1,7 @@ (include *standard-cl-23*) -(import games.krunk.onchain.guess exposing (program_hash as guess_hash)) -(import games.krunk.onchain.krunk_make_clue exposing make_clue) +(import games.krunk.clsp.onchain.guess exposing (program_hash as guess_hash)) +(import games.krunk.clsp.onchain.krunk_make_clue exposing make_clue) (import std.if_any_fail) (import std.assert) (import std.and) @@ -27,7 +27,7 @@ ; state is (dict_pubkey base_unit bob_guesses alice_clues alice_commit clue_hash) ; MOVE is either a 1-byte clue or a 21-byte salt||word reveal. ; On a continue (clue), we transition back to guess. guess_hash is imported -; directly from games.krunk.onchain.guess. +; directly from games.krunk.clsp.onchain.guess. (export (mod_hash (MOVER_PUBKEY WAITER_PUBKEY TIMEOUT AMOUNT MOD_HASH NONCE MOVE MAX_MOVE_SIZE VALIDATION_INFO_HASH MOVER_SHARE PREVIOUS_VALIDATION_INFO_HASH) diff --git a/clsp/games/krunk/onchain/commit.clsp b/games/krunk/clsp/onchain/commit.clsp similarity index 84% rename from clsp/games/krunk/onchain/commit.clsp rename to games/krunk/clsp/onchain/commit.clsp index d178162c9..ea0ab5bfd 100644 --- a/clsp/games/krunk/onchain/commit.clsp +++ b/games/krunk/clsp/onchain/commit.clsp @@ -1,7 +1,7 @@ (include *standard-cl-23*) -(import games.krunk.onchain.guess exposing (program_hash as guess_hash)) -(import games.krunk.onchain.clue exposing (program_hash as clue_hash)) +(import games.krunk.clsp.onchain.guess exposing (program_hash as guess_hash)) +(import games.krunk.clsp.onchain.clue exposing (program_hash as clue_hash)) (import std.if_any_fail) (import std.and) (import std.li) diff --git a/clsp/games/krunk/onchain/guess.clsp b/games/krunk/clsp/onchain/guess.clsp similarity index 100% rename from clsp/games/krunk/onchain/guess.clsp rename to games/krunk/clsp/onchain/guess.clsp diff --git a/clsp/games/krunk/onchain/krunk_make_clue.clinc b/games/krunk/clsp/onchain/krunk_make_clue.clinc similarity index 100% rename from clsp/games/krunk/onchain/krunk_make_clue.clinc rename to games/krunk/clsp/onchain/krunk_make_clue.clinc diff --git a/clsp/games/krunk/onchain/krunk_validator_hashes.clinc b/games/krunk/clsp/onchain/krunk_validator_hashes.clinc similarity index 100% rename from clsp/games/krunk/onchain/krunk_validator_hashes.clinc rename to games/krunk/clsp/onchain/krunk_validator_hashes.clinc diff --git a/src/bin/gen_krunk_dict.rs b/games/krunk/rust/bin_gen_krunk_dict.rs similarity index 96% rename from src/bin/gen_krunk_dict.rs rename to games/krunk/rust/bin_gen_krunk_dict.rs index 022102b49..bab6f677e 100644 --- a/src/bin/gen_krunk_dict.rs +++ b/games/krunk/rust/bin_gen_krunk_dict.rs @@ -36,7 +36,7 @@ fn main() { dat.extend_from_slice(&pk_bytes); dat.extend_from_slice(tree_bytes); - let dat_path = "clsp/games/krunk/krunk_signed_dict_tree.dat"; + let dat_path = "games/krunk/clsp/krunk_signed_dict_tree.dat"; std::fs::write(dat_path, &dat).expect("write dat"); eprintln!( diff --git a/src/games/krunk_dict_tree.rs b/games/krunk/rust/dict_tree.rs similarity index 99% rename from src/games/krunk_dict_tree.rs rename to games/krunk/rust/dict_tree.rs index 006fff282..8210ca93e 100644 --- a/src/games/krunk_dict_tree.rs +++ b/games/krunk/rust/dict_tree.rs @@ -179,10 +179,8 @@ pub fn sigs_from_bytes(blob: &[u8]) -> Result, Error> { ))); } let mut sigs = Vec::with_capacity(blob.len() / 96); - for chunk in blob.chunks_exact(96) { - let mut fixed = [0u8; 96]; - fixed.copy_from_slice(chunk); - sigs.push(Aggsig::from_bytes(fixed).unwrap_or_default()); + for chunk in blob.as_chunks::<96>().0 { + sigs.push(Aggsig::from_bytes(*chunk).unwrap_or_default()); } Ok(sigs) } diff --git a/games/krunk/rust/mod.rs b/games/krunk/rust/mod.rs new file mode 100644 index 000000000..87e31182a --- /dev/null +++ b/games/krunk/rust/mod.rs @@ -0,0 +1,45 @@ +use chia_protocol::Bytes; +use clvm_traits::{clvm_curried_args, ToClvm}; +use clvm_utils::CurriedProgram; + +use crate::common::load_clvm::{read_hex_puzzle, read_krunk_dict_dat}; +use crate::common::types::{AllocEncoder, Error, IntoErr, Program}; +use crate::session_phases::types::GameFactory; + +pub mod dict_tree; + +pub const FACTORY_HEX: &str = "games/krunk/clsp/factory_krunk_factory.hex"; +pub const DICT_DAT: &str = "games/krunk/clsp/krunk_signed_dict_tree.dat"; + +/// Loads the krunk dictionary from `krunkwords.txt`, embedded at compile time. +pub fn dictionary() -> Vec { + include_str!("../clsp/krunkwords.txt") + .lines() + .filter(|l| l.len() == 5) + .map(|w| Bytes::from(w.as_bytes().to_vec())) + .collect() +} + +pub fn prepared_factory(allocator: &mut AllocEncoder) -> Result { + let factory_raw = read_hex_puzzle(allocator, FACTORY_HEX)?; + let (dict_pubkey, dict_tree) = read_krunk_dict_dat(allocator, DICT_DAT)?; + let factory_node = CurriedProgram { + program: factory_raw, + args: clvm_curried_args!(dict_pubkey, dict_tree), + } + .to_clvm(allocator) + .into_gen()?; + let factory = Program::from_nodeptr(allocator, factory_node)?; + Ok(GameFactory { + program: Some(factory.into()), + }) +} + +/// Canonical probe: 100-mojo stake (a valid Krunk multiple of 100). +pub fn probe_parameters(allocator: &mut AllocEncoder) -> Result { + let node = 100u64.to_clvm(allocator).into_gen()?; + Program::from_nodeptr(allocator, node) +} + +#[cfg(test)] +pub mod tests; diff --git a/src/tests/dict_tree_lookup.rs b/games/krunk/rust/tests/dict_tree_lookup.rs similarity index 100% rename from src/tests/dict_tree_lookup.rs rename to games/krunk/rust/tests/dict_tree_lookup.rs diff --git a/src/tests/krunk_handlers.rs b/games/krunk/rust/tests/handlers.rs similarity index 99% rename from src/tests/krunk_handlers.rs rename to games/krunk/rust/tests/handlers.rs index fc8b5755b..c63bfd067 100644 --- a/src/tests/krunk_handlers.rs +++ b/games/krunk/rust/tests/handlers.rs @@ -225,7 +225,7 @@ struct GameSetup { fn setup_game(allocator: &mut AllocEncoder, dictionary: Vec) -> GameSetup { let factory_raw = read_hex_puzzle( allocator, - "clsp/games/krunk/krunk_include_krunk_factory.hex", + "games/krunk/clsp/factory_krunk_factory.hex", ) .expect("load factory"); @@ -276,7 +276,7 @@ fn test_dictionary() -> Vec { fn factory_puzzle(allocator: &mut AllocEncoder, dictionary: &[Bytes]) -> Puzzle { let factory_raw = read_hex_puzzle( allocator, - "clsp/games/krunk/krunk_include_krunk_factory.hex", + "games/krunk/clsp/factory_krunk_factory.hex", ) .expect("load factory"); let sigs: Vec = (0..=dictionary.len()).map(|_| Aggsig::default()).collect(); @@ -601,7 +601,7 @@ fn test_krunk_bob_invalid_guess_slash() { let state = proper_list(allocator.allocator(), val_result, true).unwrap()[1]; let guess_validator = - read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/guess.hex").unwrap(); + read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/guess.hex").unwrap(); let guess_hash = validator_hash_node(&mut allocator, &guess_validator); // Alice processes an invalid on-chain guess (Bob cheated past handler checks) @@ -1025,7 +1025,7 @@ fn test_krunk_bob_detects_wrong_clue() { let mut allocator = AllocEncoder::new(); let clue_validator = - read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/clue.hex").unwrap(); + read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/clue.hex").unwrap(); let word = b"world"; let salt = [0x77; 16]; diff --git a/games/krunk/rust/tests/mod.rs b/games/krunk/rust/tests/mod.rs new file mode 100644 index 000000000..f9e81aae4 --- /dev/null +++ b/games/krunk/rust/tests/mod.rs @@ -0,0 +1,13 @@ +pub mod dict_tree_lookup; +pub mod handlers; +pub mod sim; +pub mod validation; + +pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { + let mut funs = handlers::test_funs(); + funs.extend(validation::test_funs()); + funs.extend(dict_tree_lookup::test_funs()); + #[cfg(feature = "sim-tests")] + funs.extend(sim::test_funs()); + funs +} diff --git a/src/test_support/krunk_sim.rs b/games/krunk/rust/tests/sim.rs similarity index 84% rename from src/test_support/krunk_sim.rs rename to games/krunk/rust/tests/sim.rs index e6eef1416..2dd17e472 100644 --- a/src/test_support/krunk_sim.rs +++ b/games/krunk/rust/tests/sim.rs @@ -84,10 +84,11 @@ mod sim_tests { use crate::channel_state::types::{ChannelEnv, OnChainGameState, TimeoutClaimState}; use crate::common::types::{Amount, CoinString, Hash, PuzzleHash, Timeout}; use crate::session_phases::effects::{ - ChannelStatus, ChannelStatusSnapshot, GameNotification, GameStatusKind, SettlementOutcome, + ChannelStatus, ChannelStatusSnapshot, GameNotification, GameStatusKind, LocalActionKind, + SettlementOutcome, }; use crate::session_phases::on_chain::{OnChainPhase, OnChainPhaseArgs}; - use crate::session_phases::types::{GameAction, PotatoState}; + use crate::session_phases::types::{GameAction, PeerMessage, PotatoState}; use crate::simulator::tests::session_phases_sim::{ run_krunk_container_with_action_list_with_success_predicate, GameRunOutcome, TestEvent, }; @@ -399,6 +400,30 @@ mod sim_tests { match result { Ok(outcome) => { assert_stayed_off_chain(&outcome, "test_play_krunk_happy_path"); + let player_0_moves = outcome.local_uis[0] + .notifications + .iter() + .filter(|notification| matches!( + notification, + GameNotification::LocalActionApplied { + id: GameID(1), + action: LocalActionKind::MakeMove, + } + )) + .count(); + let player_1_moves = outcome.local_uis[1] + .notifications + .iter() + .filter(|notification| matches!( + notification, + GameNotification::LocalActionApplied { + id: GameID(1), + action: LocalActionKind::MakeMove, + } + )) + .count(); + assert_eq!(player_0_moves, 1); + assert_eq!(player_1_moves, 1); } Err(e) => { panic!("krunk happy path failed; error={e:?}"); @@ -441,6 +466,13 @@ mod sim_tests { assert!(!notifications .iter() .any(|notification| matches!(notification, GameNotification::ActionFailed { .. }))); + assert!(!notifications.iter().any(|notification| matches!( + notification, + GameNotification::LocalActionApplied { + id: GameID(1), + action: LocalActionKind::MakeMove, + } + ))); assert!(!notifications.iter().any(|notification| matches!( notification, GameNotification::GameSettled { .. } @@ -452,6 +484,102 @@ mod sim_tests { ))); })); + res.push(("test_krunk_move_applies_after_potato_returns", &|| { + let mut allocator = AllocEncoder::new(); + let valid_word = word_program(&mut allocator, b"CRANE"); + let request_potato = + bencodex::to_vec(&PeerMessage::RequestPotato(())).expect("serialize request"); + let moves = vec![ + SimScriptAction::ProposeNewGame(0, ProposeTrigger::Channel), + SimScriptAction::AcceptProposal(1, GameID(1)), + // Give away the potato without changing the game turn, then + // queue the move while player 0 still has move authority. + SimScriptAction::InjectRawMessage(0, request_potato), + SimScriptAction::Move( + 0, + GameID(1), + ReadableMove::from_program(Rc::new(valid_word)), + true, + ), + SimScriptAction::WaitBlocks(1, 0), + ]; + let move_count = moves.len(); + let outcome = run_krunk_container_with_action_list_with_success_predicate( + &mut allocator, + &moves, + Some(&|move_number, cradles| { + move_number >= move_count + && cradles[0] + .historical_unroll_count() + .is_some_and(|count| count >= 5) + }), + None, + ) + .expect("queued move should apply after the potato returns"); + + let notifications = &outcome.local_uis[0].notifications; + assert_eq!( + notifications + .iter() + .filter(|notification| matches!( + notification, + GameNotification::LocalActionApplied { + id: GameID(1), + action: LocalActionKind::MakeMove, + } + )) + .count(), + 1, + "queued move should emit once after potato return: {notifications:?}" + ); + })); + + res.push(("test_krunk_rejection_after_potato_returns_is_not_applied", &|| { + let mut allocator = AllocEncoder::new(); + let invalid_word = word_program(&mut allocator, b"XXXXX"); + let request_potato = + bencodex::to_vec(&PeerMessage::RequestPotato(())).expect("serialize request"); + let moves = vec![ + SimScriptAction::ProposeNewGame(0, ProposeTrigger::Channel), + SimScriptAction::AcceptProposal(1, GameID(1)), + SimScriptAction::InjectRawMessage(0, request_potato), + SimScriptAction::Move( + 0, + GameID(1), + ReadableMove::from_program(Rc::new(invalid_word)), + true, + ), + SimScriptAction::AcceptSettlement(0, GameID(1)), + SimScriptAction::WaitBlocks(1, 0), + ]; + let move_count = moves.len(); + let outcome = run_krunk_container_with_action_list_with_success_predicate( + &mut allocator, + &moves, + Some(&|move_number, cradles| { + move_number >= move_count + && cradles[0] + .historical_unroll_count() + .is_some_and(|count| count >= 5) + }), + None, + ) + .expect("queued rejection should remain recoverable"); + + let notifications = &outcome.local_uis[0].notifications; + assert!(notifications.iter().any(|notification| matches!( + notification, + GameNotification::MoveRejected { id: GameID(1), .. } + ))); + assert!(!notifications.iter().any(|notification| matches!( + notification, + GameNotification::LocalActionApplied { + id: GameID(1), + action: LocalActionKind::MakeMove, + } + ))); + })); + res.push(("test_play_krunk_clean_shutdown", &|| { let mut allocator = AllocEncoder::new(); let mut moves = full_group_moves(&mut allocator); diff --git a/src/tests/krunk_validation.rs b/games/krunk/rust/tests/validation.rs similarity index 95% rename from src/tests/krunk_validation.rs rename to games/krunk/rust/tests/validation.rs index cd643911e..853537c1b 100644 --- a/src/tests/krunk_validation.rs +++ b/games/krunk/rust/tests/validation.rs @@ -157,7 +157,7 @@ fn words_to_list(allocator: &mut AllocEncoder, words: &[&[u8; 5]]) -> NodePtr { fn test_krunk_commit_happy() { let mut allocator = AllocEncoder::new(); - let commit = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/commit.hex").unwrap(); + let commit = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/commit.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let base_unit_node = BASE_UNIT.to_clvm(&mut allocator).unwrap(); let initial_state = { @@ -183,7 +183,7 @@ fn test_krunk_commit_happy() { fn test_krunk_commit_slash_bad_move_size() { let mut allocator = AllocEncoder::new(); - let commit = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/commit.hex").unwrap(); + let commit = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/commit.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let base_unit_node = BASE_UNIT.to_clvm(&mut allocator).unwrap(); let initial_state = { @@ -207,7 +207,7 @@ fn test_krunk_commit_slash_bad_move_size() { fn test_krunk_guess_happy() { let mut allocator = AllocEncoder::new(); - let guess = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/guess.hex").unwrap(); + let guess = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/guess.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let state = make_state_after_commit(&mut allocator, dict_pubkey, [0xCD; 32]); let (code, result) = @@ -224,7 +224,7 @@ fn test_krunk_guess_happy() { fn test_krunk_guess_slash_bob_out_of_dict() { let mut allocator = AllocEncoder::new(); - let guess = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/guess.hex").unwrap(); + let guess = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/guess.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let state = make_state_after_commit(&mut allocator, dict_pubkey, [0xCD; 32]); @@ -258,7 +258,7 @@ fn test_krunk_guess_slash_bob_out_of_dict() { fn test_krunk_guess_bad_range_doesnt_bracket() { let mut allocator = AllocEncoder::new(); - let guess = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/guess.hex").unwrap(); + let guess = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/guess.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let state = make_state_after_commit(&mut allocator, dict_pubkey, [0xCD; 32]); @@ -280,7 +280,7 @@ fn test_krunk_guess_bad_range_doesnt_bracket() { fn test_krunk_clue_nonterminal_happy() { let mut allocator = AllocEncoder::new(); - let clue = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/clue.hex").unwrap(); + let clue = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/clue.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let bob_guesses = words_to_list(&mut allocator, &[b"crane"]); @@ -307,7 +307,7 @@ fn test_krunk_clue_nonterminal_happy() { fn test_krunk_clue_blocks_5th_clue() { let mut allocator = AllocEncoder::new(); - let clue = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/clue.hex").unwrap(); + let clue = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/clue.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); // 4 clues already given (alice_clues has 4 elements) @@ -345,7 +345,7 @@ fn test_krunk_clue_blocks_5th_clue() { fn test_krunk_reveal_slash_alice_out_of_dict() { let mut allocator = AllocEncoder::new(); - let clue = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/clue.hex").unwrap(); + let clue = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/clue.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let bob_guesses = words_to_list(&mut allocator, &[b"crane"]); @@ -390,7 +390,7 @@ fn test_krunk_reveal_slash_alice_out_of_dict() { fn test_krunk_reveal_bad_range_doesnt_bracket() { let mut allocator = AllocEncoder::new(); - let clue = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/clue.hex").unwrap(); + let clue = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/clue.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let bob_guesses = words_to_list(&mut allocator, &[b"crane"]); @@ -423,7 +423,7 @@ fn test_krunk_reveal_bad_range_doesnt_bracket() { fn test_krunk_reveal_valid() { let mut allocator = AllocEncoder::new(); - let clue = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/clue.hex").unwrap(); + let clue = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/clue.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let word = b"crane"; @@ -476,7 +476,7 @@ fn test_krunk_reveal_valid() { fn test_krunk_clue_all_correct_byte_rejected() { let mut allocator = AllocEncoder::new(); - let clue = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/clue.hex").unwrap(); + let clue = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/clue.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let bob_guesses = words_to_list(&mut allocator, &[b"crane"]); @@ -498,7 +498,7 @@ fn test_krunk_clue_all_correct_byte_rejected() { fn test_krunk_clue_above_range_rejected() { let mut allocator = AllocEncoder::new(); - let clue = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/clue.hex").unwrap(); + let clue = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/clue.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let bob_guesses = words_to_list(&mut allocator, &[b"crane"]); @@ -520,7 +520,7 @@ fn test_krunk_clue_above_range_rejected() { fn test_krunk_clue_nonzero_mover_share_rejected() { let mut allocator = AllocEncoder::new(); - let clue = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/clue.hex").unwrap(); + let clue = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/clue.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let bob_guesses = words_to_list(&mut allocator, &[b"crane"]); @@ -544,7 +544,7 @@ fn test_krunk_clue_nonzero_mover_share_rejected() { fn test_krunk_guess_wrong_length_rejected() { let mut allocator = AllocEncoder::new(); - let guess = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/guess.hex").unwrap(); + let guess = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/guess.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let state = make_state_after_commit(&mut allocator, dict_pubkey, [0xCD; 32]); @@ -574,7 +574,7 @@ fn test_krunk_guess_wrong_length_rejected() { fn test_krunk_guess_nonzero_mover_share_rejected() { let mut allocator = AllocEncoder::new(); - let guess = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/guess.hex").unwrap(); + let guess = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/guess.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let state = make_state_after_commit(&mut allocator, dict_pubkey, [0xCD; 32]); @@ -601,7 +601,7 @@ fn make_commit_for(salt: &[u8; 16], word: &[u8; 5]) -> [u8; 32] { fn test_krunk_reveal_claims_won_but_latest_guess_wrong() { let mut allocator = AllocEncoder::new(); - let clue = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/clue.hex").unwrap(); + let clue = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/clue.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let word = b"world"; @@ -652,7 +652,7 @@ fn test_krunk_reveal_claims_won_but_latest_guess_wrong() { fn test_krunk_reveal_claims_won_but_not_terminal() { let mut allocator = AllocEncoder::new(); - let clue = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/clue.hex").unwrap(); + let clue = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/clue.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let word = b"world"; @@ -691,7 +691,7 @@ fn test_krunk_reveal_claims_won_but_not_terminal() { fn test_krunk_reveal_wrong_mover_share_amount() { let mut allocator = AllocEncoder::new(); - let clue = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/clue.hex").unwrap(); + let clue = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/clue.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let word = b"crane"; @@ -739,7 +739,7 @@ fn test_krunk_reveal_wrong_mover_share_amount() { fn test_krunk_reveal_bad_commit() { let mut allocator = AllocEncoder::new(); - let clue = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/clue.hex").unwrap(); + let clue = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/clue.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let word = b"crane"; @@ -778,7 +778,7 @@ fn test_krunk_reveal_bad_commit() { fn test_krunk_reveal_wrong_clue_slash() { let mut allocator = AllocEncoder::new(); - let clue = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/clue.hex").unwrap(); + let clue = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/clue.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let word = b"crane"; @@ -829,7 +829,7 @@ fn test_krunk_reveal_wrong_clue_slash() { fn test_krunk_reveal_correct_clue_no_slash() { let mut allocator = AllocEncoder::new(); - let clue = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/clue.hex").unwrap(); + let clue = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/clue.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let word = b"crane"; @@ -881,7 +881,7 @@ fn make_n_clues(allocator: &mut AllocEncoder, n: usize) -> NodePtr { fn test_reveal_payout_at_depth(depth: usize, expected_mover_share: i64) { let mut allocator = AllocEncoder::new(); - let clue = read_hex_puzzle(&mut allocator, "clsp/games/krunk/onchain/clue.hex").unwrap(); + let clue = read_hex_puzzle(&mut allocator, "games/krunk/clsp/onchain/clue.hex").unwrap(); let dict_pubkey = make_dict_pubkey(&mut allocator); let word = b"crane"; diff --git a/front-end/src/features/krunk/Krunk.tsx b/games/krunk/ui/Krunk.tsx similarity index 91% rename from front-end/src/features/krunk/Krunk.tsx rename to games/krunk/ui/Krunk.tsx index 448601443..c38d8a40f 100644 --- a/front-end/src/features/krunk/Krunk.tsx +++ b/games/krunk/ui/Krunk.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; -import { Observable } from 'rxjs'; import { useKrunkHand, canDraftKrunkGuess, @@ -11,22 +10,21 @@ import { KrunkGuess, KrunkRole, } from './useKrunkHand'; -import { GameplayEvent } from '../../hooks/useGameSession'; -import { formatAmount } from '../../util'; -import type { PersistedGameState } from '../../lib/session/gameStateCodec'; -import type { GameTerminalModel } from '../../lib/session/types'; -import { type GameHandSource, useInitialGameHandState } from '../../lib/gameMount'; -import { krunkStateCodec } from './stateCodec'; +import { + defaultFormatAmount, + gameHandState, + type GameHandSource, + type GameTerminalModel, + type PersistedGameState, +} from '../../host'; +import { useGameHost } from '../../host/ui'; +import { krunkStateCodec } from './serialize'; export interface KrunkProps { handSource: GameHandSource; currentHandGameIds: string[]; activeGameIds: string[]; - iProposedHand: boolean; - gameplayEvent$: Observable; - betSize: bigint; - onTurnChanged: (gameId: string, isMyTurn: boolean) => void; - onGameLog: (lines: string[]) => void; + onGameLog?: (lines: string[]) => void; myName?: string; opponentName?: string; terminalsById: Record; @@ -41,6 +39,7 @@ export function formatKrunkHandLog( betSize: bigint, guesses: KrunkGuess[], revealedWord: string | null, + formatAmount: (mojos: bigint) => string = defaultFormatAmount, ): string[] { const roleLabel = role === 'alice' ? 'picking' : 'guessing'; const lines = [`Krunk (${roleLabel}) ${formatAmount(betSize)}`]; @@ -56,38 +55,28 @@ export function formatKrunkHandLog( export function krunkGameSlots( currentHandGameIds: string[], - iProposedHand: boolean, activeGameIds: string[] = currentHandGameIds, - persistedState?: PersistedGameState, + persistedState?: PersistedGameState | null, ): { aliceGameId: string | null; bobGameId: string | null; aliceActive: boolean; bobActive: boolean; } { - const first = currentHandGameIds[0] ?? null; - const second = currentHandGameIds[1] ?? null; const persistedGames = krunkStateCodec.decode(persistedState)?.games; + if (!persistedGames) { + throw new Error('Krunk requires initialized durable game state'); + } const persistedAlice = currentHandGameIds.find((id) => persistedGames?.[id]?.role === 'alice'); const persistedBob = currentHandGameIds.find((id) => persistedGames?.[id]?.role === 'bob'); - if ( - persistedGames && - currentHandGameIds.every((id) => persistedGames[id]) && - (!persistedAlice || !persistedBob) - ) { + if (!currentHandGameIds.every((id) => persistedGames[id]) || !persistedAlice || !persistedBob) { throw new Error('Krunk persisted roles must contain one alice and one bob'); } - const fallback = iProposedHand - ? { aliceGameId: first, bobGameId: second } - : { aliceGameId: second, bobGameId: first }; - const slots = - persistedAlice && persistedBob - ? { aliceGameId: persistedAlice, bobGameId: persistedBob } - : fallback; return { - ...slots, - aliceActive: slots.aliceGameId !== null && activeGameIds.includes(slots.aliceGameId), - bobActive: slots.bobGameId !== null && activeGameIds.includes(slots.bobGameId), + aliceGameId: persistedAlice, + bobGameId: persistedBob, + aliceActive: activeGameIds.includes(persistedAlice), + bobActive: activeGameIds.includes(persistedBob), }; } @@ -439,29 +428,27 @@ const Krunk: React.FC = ({ handSource, currentHandGameIds, activeGameIds, - iProposedHand, - gameplayEvent$, - betSize, - onTurnChanged, onGameLog, myName: _myName, opponentName, terminalsById, amountsById, }) => { + const { formatAmount } = useGameHost(); const interactive = handSource.interactionMode === 'live'; - const initialPersistedState = useInitialGameHandState(handSource) ?? undefined; - // The hand proposer sent game 0 with my_turn=true (proposer is alice) - // and game 1 with my_turn=false (proposer is bob). The acceptor's - // roles are flipped: they're bob in game 0 and alice in game 1. + const persistedState = gameHandState(handSource); const { aliceGameId, bobGameId } = krunkGameSlots( currentHandGameIds, - iProposedHand, activeGameIds, - initialPersistedState, + persistedState, ); const aliceId = aliceGameId ?? ''; const bobId = bobGameId ?? ''; + const acceptedAmount = amountsById[aliceId] ?? amountsById[bobId]; + if (acceptedAmount === undefined) { + throw new Error('Krunk is missing the accepted game amount'); + } + const betSize = BigInt(acceptedAmount); // Keep each hand "live" for the whole atomic hand via currentHandGameIds. // activeGameIds can drop a sibling during turn/settle handoffs and was // latching useKrunkHand into a finished state (blocking clue updates and @@ -470,41 +457,13 @@ const Krunk: React.FC = ({ const bobInHand = bobGameId !== null && currentHandGameIds.includes(bobGameId); const aliceInteractive = interactive && aliceInHand; const bobInteractive = interactive && bobInHand; - const onAliceTurnChanged = useCallback( - (isMyTurn: boolean) => { - if (aliceGameId !== null) onTurnChanged(aliceGameId, isMyTurn); - }, - [aliceGameId, onTurnChanged], - ); - const onBobTurnChanged = useCallback( - (isMyTurn: boolean) => { - if (bobGameId !== null) onTurnChanged(bobGameId, isMyTurn); - }, - [bobGameId, onTurnChanged], - ); // useKrunkHand maps iStarted → role: iStarted=true means bob, false means alice. // Alice game (I pick the word): iStarted=false → role='alice'. // Bob game (I guess): iStarted=true → role='bob'. - const aliceHand = useKrunkHand( - handSource, - aliceId, - false, - gameplayEvent$, - onAliceTurnChanged, - aliceInteractive, - initialPersistedState, - ); + const aliceHand = useKrunkHand(handSource, aliceId, false, aliceInteractive); - const bobHand = useKrunkHand( - handSource, - bobId, - true, - gameplayEvent$, - onBobTurnChanged, - bobInteractive, - initialPersistedState, - ); + const bobHand = useKrunkHand(handSource, bobId, true, bobInteractive); const setAliceSecretWord = aliceHand.setSecretWord; const submitBobGuessMove = bobHand.submitGuess; @@ -517,12 +476,13 @@ const Krunk: React.FC = ({ return; } aliceLogFiredRef.current = true; - onGameLog( + onGameLog?.( formatKrunkHandLog( 'alice', betSize, aliceHand.gameState.guesses, aliceHand.gameState.revealedWord ?? aliceHand.gameState.secretWord, + formatAmount, ), ); }, [ @@ -531,6 +491,7 @@ const Krunk: React.FC = ({ aliceHand.gameState.revealedWord, aliceHand.gameState.secretWord, betSize, + formatAmount, onGameLog, ]); useEffect(() => { @@ -538,14 +499,21 @@ const Krunk: React.FC = ({ return; } bobLogFiredRef.current = true; - onGameLog( - formatKrunkHandLog('bob', betSize, bobHand.gameState.guesses, bobHand.gameState.revealedWord), + onGameLog?.( + formatKrunkHandLog( + 'bob', + betSize, + bobHand.gameState.guesses, + bobHand.gameState.revealedWord, + formatAmount, + ), ); }, [ bobHand.gameState.handler, bobHand.gameState.guesses, bobHand.gameState.revealedWord, betSize, + formatAmount, onGameLog, ]); diff --git a/games/krunk/ui/handProposal.ts b/games/krunk/ui/handProposal.ts new file mode 100644 index 000000000..c6a5cd166 --- /dev/null +++ b/games/krunk/ui/handProposal.ts @@ -0,0 +1,127 @@ +import { Program } from 'clvm-lib'; +import { + equalHandProposalBase, + readClvmAtom, + readClvmProgram, + type FactoryParameterCodec, + type GameFeatureRegistration, + type HandProposal, +} from '../../host'; +import { + decodeKrunkGameState, + krunkStateCodec, + reduceKrunkDurableState, + type KrunkGameState, + type KrunkHandState, +} from './serialize'; + +export { + applyKrunkMoveRejected, + krunkOutcomeFromPlay, + reduceKrunkDurableState, + reduceKrunkFeatureState, +} from './serialize'; + +export type KrunkFactoryParameters = { + stake: bigint; +}; + +export const krunkFactoryParameters: FactoryParameterCodec = { + decode(value) { + const program = readClvmProgram(value); + if (!program || program.isCons) return null; + const stake = readClvmAtom(program); + if (stake === null || stake <= 0n) return null; + return { stake }; + }, + encode: (params) => Program.fromBigInt(params.stake), +}; + +export function isValidKrunkStake(stake: bigint): boolean { + return stake > 0n && stake % 100n === 0n; +} + +export function validateKrunkHandProposal(handProposal: HandProposal): boolean { + return ( + handProposal.myContribution === handProposal.theirContribution && + isValidKrunkStake(handProposal.myContribution) && + handProposal.gameTimeout > 0n + ); +} + +const registration: GameFeatureRegistration< + KrunkHandState, + KrunkGameState, + { amount: bigint }, + KrunkFactoryParameters +> = { + gameType: 'krunk', + displayName: 'Krunk', + stateCodec: krunkStateCodec, + factoryParameters: krunkFactoryParameters, + describeHandProposal: (handProposal, { formatMojos }) => + `Stake ${formatMojos(handProposal.myContribution)} each`, + handMembershipDescription: + 'exactly two ordered currentHandGameIds whose payload IDs exactly match currentHandGameIds in order', + validateHandMembership(gameIds, state) { + if (gameIds.length !== 2) return false; + if (state === null) return true; + const payloadIds = Object.keys(state.games); + return ( + payloadIds.length === 2 && + payloadIds.every((id, index) => id === gameIds[index]) && + state.games[gameIds[0]].role !== state.games[gameIds[1]].role + ); + }, + decodeFeatureState: decodeKrunkGameState, + selectOutcome: (state, gameId) => { + const outcome = state.games[gameId]?.outcome; + return outcome ? { my_win_outcome: outcome } : null; + }, + lifecycle: { + proposalSenderGoesFirst: (iStarted) => !iStarted, + }, + draft: { + default: () => ({ amount: 100n }), + fromHandProposal: (handProposal) => ({ amount: handProposal.myContribution }), + update: (current, update) => ({ ...current, ...update }), + toHandProposal(draft, gameTimeout) { + const handProposal = { + gameType: 'krunk', + myContribution: draft.amount, + theirContribution: draft.amount, + gameTimeout, + }; + return validateKrunkHandProposal(handProposal) ? handProposal : null; + }, + }, + toFactoryParameters: (handProposal) => ({ stake: handProposal.myContribution }), + decodeHandProposal(base, params) { + if (params.stake !== base.myContribution) return null; + const handProposal = { gameType: 'krunk', ...base }; + return validateKrunkHandProposal(handProposal) ? handProposal : null; + }, + validateHandProposal: validateKrunkHandProposal, + handProposalsEqual: equalHandProposalBase, + persistence: { + encodeExtras: () => ({}), + decodeExtras(base) { + const handProposal = { gameType: 'krunk', ...base }; + return validateKrunkHandProposal(handProposal) ? handProposal : null; + }, + }, + durableState: { + initialize(current, input) { + return reduceKrunkDurableState(current, input)!; + }, + reduceInput(current, input) { + return reduceKrunkDurableState(current, input)!; + }, + applyFeatureState(current, gameId, state) { + return { games: { ...current.games, [gameId]: state } }; + }, + }, +}; + +export const krunkRegistration = registration; +export default registration; diff --git a/games/krunk/ui/handProposalForm.tsx b/games/krunk/ui/handProposalForm.tsx new file mode 100644 index 000000000..96ed76370 --- /dev/null +++ b/games/krunk/ui/handProposalForm.tsx @@ -0,0 +1,38 @@ +import { AmountInput, useGameHost } from '../../host/ui'; +import type { HandProposalFormProps } from '../../host'; +import { isValidKrunkStake } from './handProposal'; + +export function HandProposalForm({ + draft, + disabled, + maxPerHandMojos, + onChange, + onSubmit, +}: HandProposalFormProps<{ amount: bigint }>) { + const { currencyLabels } = useGameHost(); + const maxMojos = + maxPerHandMojos != null ? maxPerHandMojos - (maxPerHandMojos % 100n) : maxPerHandMojos; + return ( + <> + onChange({ amount })} + maxMojos={maxMojos} + onUseMax={ + maxMojos != null && maxMojos > 0n ? () => onChange({ amount: maxMojos }) : undefined + } + disabled={disabled} + label="Per-player stake" + exceedsLabel="Exceeds available reserve." + onKeyDown={(event) => { + if (event.key === 'Enter') onSubmit(); + }} + /> + {draft.amount > 0n && !isValidKrunkStake(draft.amount) && ( +

+ Krunk stakes must be multiples of 100 {currencyLabels.mojos}. +

+ )} + + ); +} diff --git a/front-end/src/features/krunk/krunk.test.ts b/games/krunk/ui/krunk.test.ts similarity index 77% rename from front-end/src/features/krunk/krunk.test.ts rename to games/krunk/ui/krunk.test.ts index 6dd047899..eebf5c9d9 100644 --- a/front-end/src/features/krunk/krunk.test.ts +++ b/games/krunk/ui/krunk.test.ts @@ -1,9 +1,7 @@ import React from 'react'; import { act, create, type ReactTestRenderer } from 'react-test-renderer'; -import { EMPTY, Subject } from 'rxjs'; import { KrunkHandler, - applyKrunkMoveRejected, canDraftKrunkGuess, canQueueKrunkGuess, isKrunkDictionaryRejectionError, @@ -14,13 +12,7 @@ import { krunkWinMessage, type KrunkGameState, } from './useKrunkHand'; -import { - gameplayEventForMoveRejected, - gameplayEventsForGameStatus, - parseTermsFromNotificationValue, -} from '../../hooks/useGameSession'; -import { createSessionModel, selectProposalGroupByMemberId } from '../../lib/session/model'; -import { isValidKrunkStake } from './adapter'; +import { isValidKrunkStake } from './handProposal'; import { formatKrunkHandLog, krunkGameSlots, @@ -29,10 +21,13 @@ import { type KrunkProps, } from './Krunk'; import Krunk from './Krunk'; -import { initialKrunkGameState, krunkStateCodec } from './stateCodec'; -import type { SessionController } from '../../hooks/SessionController'; -import type { LocalGameActionRequest } from '../../lib/session/sessionMachineTypes'; -import type { GameTerminalModel } from '../../lib/session/types'; +import { + applyKrunkMoveRejected, + initialKrunkGameState, + krunkStateCodec, + type KrunkHandState, +} from './serialize'; +import { type GameTerminalModel, type LiveGamePort } from '../../host'; function terminal( outcome: GameTerminalModel['outcome'] = null, @@ -48,32 +43,6 @@ function terminal( } describe('Krunk terms', () => { - it('derives both member lookups from one normalized group', () => { - const terms = { - gameType: 'krunk', - myContribution: 100n, - theirContribution: 100n, - gameTimeout: 15n, - } as const; - const model = createSessionModel({ - betweenHand: { - proposalGroups: [ - { - primaryId: '1', - memberIds: ['1', '3'], - terms, - origin: 'local', - disposition: 'outgoing', - }, - ], - }, - }); - - expect(selectProposalGroupByMemberId(model, '1')).toBe( - selectProposalGroupByMemberId(model, '3'), - ); - }); - it('requires positive 100-mojo stake increments', () => { expect(isValidKrunkStake(0n)).toBe(false); expect(isValidKrunkStake(99n)).toBe(false); @@ -81,24 +50,6 @@ describe('Krunk terms', () => { expect(isValidKrunkStake(200n)).toBe(true); expect(isValidKrunkStake(201n)).toBe(false); }); - - it('keeps the aggregate per-player contributions from a grouped proposal', () => { - expect( - parseTermsFromNotificationValue( - { - my_contribution: { Amount: '300' }, - their_contribution: { Amount: '300' }, - timeout: 15, - }, - 'krunk', - ), - ).toEqual({ - gameType: 'krunk', - myContribution: 300n, - theirContribution: 300n, - gameTimeout: 15n, - }); - }); }); describe('Krunk draft continuity', () => { @@ -124,22 +75,12 @@ describe('Krunk draft continuity', () => { }, }, }); - const gameplay = new Subject(); const renderPhases: string[] = []; - const controller = { - handState: persisted, - makeMove: jest.fn(), - commitLocalGameAction: jest.fn(), - transitionFeatureState: jest.fn((_, __, state) => state), - } as unknown as SessionController; + const controller = { dispatch: jest.fn() } as LiveGamePort; const baseProps = { - handSource: { interactionMode: 'live' as const, controller }, + handSource: { interactionMode: 'live' as const, handState: persisted, port: controller }, currentHandGameIds: ['picker', 'guesser'], activeGameIds: ['picker', 'guesser'], - iProposedHand: true, - gameplayEvent$: gameplay, - betSize: 100n, - onTurnChanged: () => {}, onGameLog: () => {}, terminalsById: {}, amountsById: { picker: '100', guesser: '100' }, @@ -178,13 +119,27 @@ describe('Krunk draft continuity', () => { expect(draftLetters()).toEqual(['C', 'R', 'A']); const pickerTimeout = terminal('opponent_timed_out', '100'); + const initial = krunkStateCodec.decode(persisted) as KrunkHandState; + const pickerSettled = krunkStateCodec.encode({ + games: { + ...initial.games, + picker: { + ...initial.games.picker, + handler: KrunkHandler.Terminal, + myTurn: false, + outcome: 'win', + }, + }, + }); act(() => { - gameplay.next({ - Settled: { gameId: 'picker', outcome: 'opponent_timed_out', ourShare: '100' }, - }); renderer!.update( renderKrunk({ ...baseProps, + handSource: { + interactionMode: 'live', + handState: pickerSettled, + port: controller, + }, activeGameIds: ['guesser'], terminalsById: { picker: pickerTimeout }, }), @@ -195,17 +150,27 @@ describe('Krunk draft continuity', () => { ).toHaveLength(1); const guesserTimeout = terminal('timed_out_waiting_for_our_move', '0'); - act(() => { - gameplay.next({ - Settled: { - gameId: 'guesser', - outcome: 'timed_out_waiting_for_our_move', - ourShare: '0', + const pickerState = krunkStateCodec.decode(pickerSettled) as KrunkHandState; + const bothSettled = krunkStateCodec.encode({ + games: { + ...pickerState.games, + guesser: { + ...pickerState.games.guesser, + handler: KrunkHandler.Terminal, + myTurn: false, + outcome: 'lose', }, - }); + }, + }); + act(() => { renderer!.update( renderKrunk({ ...baseProps, + handSource: { + interactionMode: 'live', + handState: bothSettled, + port: controller, + }, activeGameIds: [], terminalsById: { picker: pickerTimeout, guesser: guesserTimeout }, }), @@ -221,7 +186,7 @@ describe('Krunk draft continuity', () => { ...baseProps, activeGameIds: [], terminalsById: { picker: pickerTimeout, guesser: guesserTimeout }, - handSource: { interactionMode: 'terminal', handState: persisted }, + handSource: { interactionMode: 'terminal', handState: bothSettled }, }), ); }); @@ -239,7 +204,7 @@ describe('Krunk draft continuity', () => { } }); - it('does not retry a feature transition when durable authority rejects the commit', () => { + it('does not keep a local durable projection when intent dispatch fails', () => { const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); Object.defineProperty(globalThis, 'window', { configurable: true, @@ -248,9 +213,7 @@ describe('Krunk draft continuity', () => { removeEventListener: jest.fn(), }, }); - const makeMove = jest.fn(); - const transitionFeatureState = jest.fn(() => false); - const commitLocalGameAction = jest.fn(() => { + const dispatch = jest.fn(() => { throw new Error('word rejected'); }); const persisted = krunkStateCodec.encode({ @@ -266,19 +229,11 @@ describe('Krunk draft continuity', () => { React.createElement(Krunk, { handSource: { interactionMode: 'live', - controller: { - handState: persisted, - makeMove, - commitLocalGameAction, - transitionFeatureState, - } as unknown as SessionController, + handState: persisted, + port: { dispatch }, }, currentHandGameIds: ['picker', 'guesser'], activeGameIds: ['picker', 'guesser'], - iProposedHand: true, - gameplayEvent$: EMPTY, - betSize: 100n, - onTurnChanged: () => {}, onGameLog: () => {}, terminalsById: {}, amountsById: { picker: '100', guesser: '100' }, @@ -294,9 +249,17 @@ describe('Krunk draft continuity', () => { const pick = root.findAllByType('button').find((button) => button.props.children === 'Pick'); expect(() => act(() => pick!.props.onClick())).toThrow('word rejected'); - expect(commitLocalGameAction).toHaveBeenCalledTimes(1); - expect(transitionFeatureState).not.toHaveBeenCalled(); - expect(makeMove).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'make-move', + gameId: 'picker', + state: expect.objectContaining({ + handler: KrunkHandler.AliceWaiting, + secretWord: 'CRANE', + }), + }), + ); act(() => renderer!.unmount()); if (windowDescriptor) { Object.defineProperty(globalThis, 'window', windowDescriptor); @@ -314,12 +277,7 @@ describe('Krunk draft continuity', () => { removeEventListener: jest.fn(), }, }); - const makeMove = jest.fn(); - const transitionFeatureState = jest.fn(() => true); - const commitLocalGameAction = jest.fn((request: LocalGameActionRequest) => { - if (request.command.type !== 'make-move') throw new Error('unexpected command'); - makeMove(request.id, request.command.readable); - }); + const dispatch = jest.fn(); const persisted = krunkStateCodec.encode({ games: { picker: initialKrunkGameState('alice'), @@ -333,19 +291,11 @@ describe('Krunk draft continuity', () => { React.createElement(Krunk, { handSource: { interactionMode: 'live', - controller: { - handState: persisted, - makeMove, - commitLocalGameAction, - transitionFeatureState, - } as unknown as SessionController, + handState: persisted, + port: { dispatch }, }, currentHandGameIds: ['picker', 'guesser'], activeGameIds: ['guesser'], - iProposedHand: true, - gameplayEvent$: EMPTY, - betSize: 100n, - onTurnChanged: () => {}, onGameLog: () => {}, terminalsById: {}, amountsById: { picker: '100', guesser: '100' }, @@ -364,17 +314,16 @@ describe('Krunk draft continuity', () => { expect(pick!.props.disabled).toBe(false); act(() => pick!.props.onClick()); - expect(commitLocalGameAction).toHaveBeenCalledWith( + expect(dispatch).toHaveBeenCalledWith( expect.objectContaining({ - gameType: 'krunk', - id: 'picker', + type: 'make-move', + gameId: 'picker', state: expect.objectContaining({ handler: KrunkHandler.AliceWaiting, secretWord: 'CRANE', }), }), ); - expect(makeMove).toHaveBeenCalledWith('picker', expect.anything()); act(() => renderer!.unmount()); if (windowDescriptor) { Object.defineProperty(globalThis, 'window', windowDescriptor); @@ -389,17 +338,29 @@ describe('Krunk draft continuity', () => { expect(newlyResolvedKrunkIndex(2, 2)).toBeUndefined(); }); - it('keeps factory-order role slots stable after one sibling ends', () => { + it('keeps durable role slots stable after one sibling ends', () => { const current = ['0', '1']; const active = ['1']; + const aliceFirst = krunkStateCodec.encode({ + games: { + '0': initialKrunkGameState('alice'), + '1': initialKrunkGameState('bob'), + }, + }); - expect(krunkGameSlots(current, true, active)).toEqual({ + expect(krunkGameSlots(current, active, aliceFirst)).toEqual({ aliceGameId: '0', bobGameId: '1', aliceActive: false, bobActive: true, }); - expect(krunkGameSlots(current, false, active)).toEqual({ + const bobFirst = krunkStateCodec.encode({ + games: { + '0': initialKrunkGameState('bob'), + '1': initialKrunkGameState('alice'), + }, + }); + expect(krunkGameSlots(current, active, bobFirst)).toEqual({ aliceGameId: '1', bobGameId: '0', aliceActive: true, @@ -407,7 +368,7 @@ describe('Krunk draft continuity', () => { }); }); - it('uses persisted roles instead of stale proposal orientation on finished restore', () => { + it('uses persisted roles on finished restore', () => { const alice = { ...initialKrunkGameState('alice'), handler: KrunkHandler.Terminal, @@ -423,7 +384,7 @@ describe('Krunk draft continuity', () => { }; const persisted = krunkStateCodec.encode({ games: { '0': alice, '1': bob } }); - expect(krunkGameSlots(['0', '1'], false, [], persisted)).toEqual({ + expect(krunkGameSlots(['0', '1'], [], persisted)).toEqual({ aliceGameId: '0', bobGameId: '1', aliceActive: false, @@ -473,46 +434,39 @@ describe('Krunk draft continuity', () => { expect(isKrunkDictionaryRejectionError(null)).toBe(false); }); - it('rolls back optimistic dictionary-rejected commits and guesses', () => { + it('reports immediate dictionary rejection without changing canonical gameplay state', () => { const alice: KrunkGameState = { - handler: KrunkHandler.AliceWaiting, - myTurn: false, + handler: KrunkHandler.WaitingCommit, + myTurn: true, role: 'alice', guesses: [], - secretWord: 'XXXXX', + secretWord: null, revealedWord: null, outcome: null, moverShare: null, error: null, }; - expect( - applyKrunkMoveRejected(alice, { - tag: 'not_in_dictionary', - message: 'xxxxx', - }), - ).toMatchObject({ - handler: KrunkHandler.WaitingCommit, - myTurn: true, - secretWord: null, + const rejectedAlice = applyKrunkMoveRejected(alice, { + tag: 'not_in_dictionary', + message: 'xxxxx', + }); + expect(rejectedAlice).toEqual({ + ...alice, error: 'XXXXX is not in the dictionary.', }); const bob: KrunkGameState = { ...alice, - handler: KrunkHandler.BobWaiting, + handler: KrunkHandler.BobGuess, role: 'bob', secretWord: null, - guesses: [{ word: 'XXXXX', clue: [-1n, -1n, -1n, -1n, -1n] }], }; - expect( - applyKrunkMoveRejected(bob, { - tag: 'not_in_dictionary', - message: 'xxxxx', - }), - ).toMatchObject({ - handler: KrunkHandler.BobGuess, - myTurn: true, - guesses: [], + const rejectedBob = applyKrunkMoveRejected(bob, { + tag: 'not_in_dictionary', + message: 'xxxxx', + }); + expect(rejectedBob).toEqual({ + ...bob, error: 'XXXXX is not in the dictionary.', }); }); @@ -809,48 +763,4 @@ describe('Krunk draft continuity', () => { '🟩🟩🟩🟩🟩FROWN', ]); }); - - it('routes a typed move rejection with its game id, tag, and message', () => { - expect( - gameplayEventForMoveRejected({ - id: 7n, - tag: 'not_in_dictionary', - message: 'xxxxx', - }), - ).toEqual({ - MoveRejected: { - gameId: '7', - tag: 'not_in_dictionary', - message: 'xxxxx', - }, - }); - }); - - it('exposes the guesser game on the first atomic-group acceptance', () => { - // First ProposalAccepted seeds activeIds and currentHandGameIds with the - // full atomic group so both Krunk panels wire immediately. - const activeIds = ['1', '3']; - expect(activeIds).toEqual(['1', '3']); - - const opponentCommit = { - GameStatus: { - id: '3', - status: 'my-turn', - coin_id: null, - other_params: { - readable: [0x80], - mover_share: '0', - }, - }, - }; - expect(gameplayEventsForGameStatus(opponentCommit, activeIds, null)).toEqual([ - { - OpponentMoved: { - readable: Uint8Array.from([0x80]), - gameId: '3', - moverShare: '0', - }, - }, - ]); - }); }); diff --git a/games/krunk/ui/play.tsx b/games/krunk/ui/play.tsx new file mode 100644 index 000000000..c29957adc --- /dev/null +++ b/games/krunk/ui/play.tsx @@ -0,0 +1,54 @@ +import { lazy, useCallback } from 'react'; +import { + gameHandSourceFromMountView, + type GameHandSource, + type GameMountRegistration, + type GameTerminalModel, +} from '../../host'; + +const Krunk = lazy(() => import('./Krunk')); + +export interface KrunkLiveMountProps { + handSource: GameHandSource; + currentHandGameIds: string[]; + activeGameIds: string[]; + appendGameLog?: (line: string) => void; + myName?: string; + opponentName?: string; + terminalsById: Record; + amountsById: Record; +} + +export function KrunkLiveMount(props: KrunkLiveMountProps) { + const { appendGameLog, ...rest } = props; + const handleGameLog = useCallback( + (lines: string[]) => { + if (!appendGameLog) return; + lines.forEach(appendGameLog); + appendGameLog(''); + }, + [appendGameLog], + ); + return ; +} + +export const play: GameMountRegistration = { + render(view) { + return ( + [id, instance.terminal]), + )} + amountsById={Object.fromEntries( + Object.entries(view.instances).map(([id, instance]) => [id, instance.amount]), + )} + myName={view.myName} + opponentName={view.opponentName} + /> + ); + }, +}; diff --git a/front-end/src/features/krunk/stateCodec.ts b/games/krunk/ui/serialize.ts similarity index 50% rename from front-end/src/features/krunk/stateCodec.ts rename to games/krunk/ui/serialize.ts index 006455087..34d848348 100644 --- a/front-end/src/features/krunk/stateCodec.ts +++ b/games/krunk/ui/serialize.ts @@ -1,4 +1,5 @@ -import { defineGameStateCodec } from '../../lib/session/gameStateCodec'; +import { Program } from 'clvm-lib'; +import { defineGameStateCodec, type GameInput } from '../../host'; export const KrunkHandler = { WaitingCommit: 0n, @@ -182,3 +183,159 @@ export function persistedKrunkGameState( }, }); } + +function parseReadable(readable: Uint8Array): { + word: string | null; + clue: KrunkGuess['clue'] | null; +} { + const program = Program.deserialize(readable); + try { + if (program.atom.length === 0) return { word: null, clue: null }; + } catch { + // Non-atom readables are the normal clue and reveal list shapes. + } + const clueFrom = (value: Program): KrunkGuess['clue'] | null => { + try { + const values = value.toList().map((item) => item.toBigInt()); + return values.length === 5 && values.every((item) => item >= 0n && item <= 2n) + ? (values as KrunkGuess['clue']) + : null; + } catch { + return null; + } + }; + const clue = clueFrom(program); + if (clue) return { word: null, clue }; + const items = program.toList(); + if (items.length !== 2) return { word: null, clue: null }; + return { + word: new TextDecoder().decode(items[0].atom).toUpperCase(), + clue: clueFrom(items[1]), + }; +} + +function finishedState( + state: KrunkGameState, + revealedWord: string | null, + clue: KrunkGuess['clue'] | null, + moverShare: string | null, +): KrunkGameState { + const correct = (value: KrunkGuess['clue']) => value.every((item) => item === 2n); + const bobWon = + state.guesses.some((guess) => correct(guess.clue)) || (clue ? correct(clue) : false); + const aliceWon = !bobWon; + return { + ...state, + handler: KrunkHandler.Terminal, + myTurn: false, + revealedWord, + moverShare, + outcome: + (state.role === 'alice' && aliceWon) || (state.role === 'bob' && bobWon) ? 'win' : 'lose', + }; +} + +type KrunkFeatureEvent = + | { type: 'opponent-moved'; readable: Uint8Array; moverShare: string | null } + | { type: 'settled' }; + +export function krunkOutcomeFromPlay(game: KrunkGameState): KrunkGameState['outcome'] { + const bobWon = game.guesses.some((guess) => guess.clue.every((item) => item === 2n)); + const finished = bobWon || game.guesses.length >= 5; + if (!finished) return null; + return game.role === 'bob' ? (bobWon ? 'win' : 'lose') : bobWon ? 'lose' : 'win'; +} + +export function reduceKrunkFeatureState( + game: KrunkGameState, + event: KrunkFeatureEvent, +): KrunkGameState { + if (event.type === 'settled') { + return { + ...game, + handler: KrunkHandler.Terminal, + myTurn: false, + outcome: game.outcome ?? krunkOutcomeFromPlay(game), + }; + } + const parsed = parseReadable(event.readable); + if (game.role === 'alice' && parsed.word && parsed.clue) { + return { + ...game, + handler: KrunkHandler.AliceClue, + myTurn: true, + guesses: [...game.guesses, { word: parsed.word, clue: parsed.clue }], + error: null, + }; + } + if (game.role === 'bob' && !parsed.word && !parsed.clue) { + return { ...game, handler: KrunkHandler.BobGuess, myTurn: true, error: null }; + } + if (game.role === 'bob' && parsed.clue && !parsed.word) { + const guesses = [...game.guesses]; + const index = guesses.length - 1; + if (index >= 0 && guesses[index].clue.every((value) => value === -1n)) { + guesses[index] = { ...guesses[index], clue: parsed.clue }; + } + const terminalClue = parsed.clue.every((value) => value === 2n) || guesses.length >= 5; + return { + ...game, + handler: terminalClue ? KrunkHandler.BobWaiting : KrunkHandler.BobGuess, + myTurn: !terminalClue, + guesses, + error: null, + }; + } + if (game.role === 'bob' && parsed.word && parsed.clue) { + const guesses = [...game.guesses]; + const index = guesses.length - 1; + if (index >= 0 && guesses[index].clue.every((value) => value === -1n)) { + guesses[index] = { ...guesses[index], clue: parsed.clue }; + } + return finishedState({ ...game, guesses }, parsed.word, parsed.clue, event.moverShare); + } + return game; +} + +export function applyKrunkMoveRejected( + state: KrunkGameState, + rejection: { tag: string; message: string }, +): KrunkGameState { + if (rejection.tag !== 'not_in_dictionary') return state; + const word = rejection.message.toUpperCase(); + return { ...state, error: `${word} is not in the dictionary.` }; +} + +export function reduceKrunkDurableState( + current: KrunkHandState | null, + event: GameInput, +): KrunkHandState | null { + if (event.type === 'hand-started') { + const games = Object.fromEntries( + event.init.gameIds.map((id, index) => { + const proposerIsAlice = index === 0; + const role = + proposerIsAlice === (event.init.origin === 'local') + ? ('alice' as const) + : ('bob' as const); + return [id, current?.games[id] ?? initialKrunkGameState(role)]; + }), + ); + return { games }; + } + if (!current?.games[event.gameId]) return current; + const game = current.games[event.gameId]; + let next = game; + if (event.type === 'hand-ended') { + next = reduceKrunkFeatureState(game, { type: 'settled' }); + } else if (event.type === 'opponent-moved') { + next = reduceKrunkFeatureState(game, { + type: 'opponent-moved', + readable: event.readable, + moverShare: event.moverShare, + }); + } else if (event.type === 'move-rejected') { + next = applyKrunkMoveRejected(game, event); + } + return { games: { ...current.games, [event.gameId]: next } }; +} diff --git a/games/krunk/ui/settlement.ts b/games/krunk/ui/settlement.ts new file mode 100644 index 000000000..2ff9e1363 --- /dev/null +++ b/games/krunk/ui/settlement.ts @@ -0,0 +1,27 @@ +import type { SettlementOutcome } from '../../host'; + +export function krunkSettlementStatus(outcome: SettlementOutcome, opponentLabel: string): string { + switch (outcome) { + case 'accept_settlement': + case 'we_accepted': + case 'settled_cleanly': + return 'Settled.'; + case 'opponent_timed_out': + return `${opponentLabel} timed out.`; + case 'forfeited_skipped_reveal': + case 'forfeited_we_accepted': + return 'We forfeited.'; + case 'lost': + return 'We lost.'; + case 'attempt_to_move_failed': + return 'Attempt to move failed.'; + case 'timed_out_waiting_for_our_move': + return 'We timed out.'; + case 'slashed_opponent': + return `Slashed ${opponentLabel}.`; + case 'opponent_slashed_us': + return `${opponentLabel} slashed us.`; + case 'opponent_cheated': + return `${opponentLabel} cheated.`; + } +} diff --git a/front-end/src/features/krunk/useKrunkHand.ts b/games/krunk/ui/useKrunkHand.ts similarity index 61% rename from front-end/src/features/krunk/useKrunkHand.ts rename to games/krunk/ui/useKrunkHand.ts index 210a662e2..aec01e9c1 100644 --- a/front-end/src/features/krunk/useKrunkHand.ts +++ b/games/krunk/ui/useKrunkHand.ts @@ -1,23 +1,30 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useEffect, useCallback, useRef } from 'react'; import { Program } from 'clvm-lib'; -import { Observable } from 'rxjs'; -import { GameplayEvent } from '../../hooks/useGameSession'; -import { requireLiveGameHandSource, type GameHandSource } from '../../lib/gameMount'; -import { getCurrencyLabels } from '../../constants/currency'; -import { krunkSettlementStatus } from '../../lib/settlement'; -import type { GameTerminalModel } from '../../lib/session/types'; -import { krunkOutcomeFromPlay, reduceKrunkFeatureState } from './adapter'; +import { + DEFAULT_CURRENCY_LABELS, + gameHandState, + requireLiveGameHandSource, + type CurrencyLabels, + type GameHandSource, + type GameTerminalModel, +} from '../../host'; +import { krunkSettlementStatus } from './settlement'; +import { krunkOutcomeFromPlay } from './handProposal'; import { krunkGameStateFromPersisted, KrunkHandler, type KrunkGameState, type KrunkGuess, type KrunkRole, -} from './stateCodec'; -import type { PersistedGameState } from '../../lib/session/gameStateCodec'; -import type { LocalGameCommand } from '../../lib/session/sessionMachineTypes'; +} from './serialize'; + +type LocalGameCommand = + | { type: 'make-move'; readable: Program | null } + | { type: 'accept-settlement' } + | { type: 'cheat'; moverShare: bigint }; export { KrunkHandler }; +export { applyKrunkMoveRejected } from './serialize'; export type { KrunkGameState, KrunkGuess, KrunkRole }; export interface UseKrunkHandResult { @@ -75,47 +82,6 @@ export function isKrunkDictionaryRejectionError(error: string | null): boolean { return error != null && error.endsWith(' is not in the dictionary.'); } -export function applyKrunkMoveRejected( - state: KrunkGameState, - rejection: { tag: string; message: string }, -): KrunkGameState { - if (rejection.tag !== 'not_in_dictionary') return state; - const word = rejection.message.toUpperCase(); - const error = `${word} is not in the dictionary.`; - - if ( - state.role === 'alice' && - state.handler === KrunkHandler.AliceWaiting && - state.secretWord === word - ) { - return { - ...state, - handler: KrunkHandler.WaitingCommit, - myTurn: true, - secretWord: null, - error, - }; - } - - const lastGuess = state.guesses[state.guesses.length - 1]; - if ( - state.role === 'bob' && - state.handler === KrunkHandler.BobWaiting && - lastGuess?.word === word && - lastGuess.clue.every((value) => value === -1n) - ) { - return { - ...state, - handler: KrunkHandler.BobGuess, - myTurn: true, - guesses: state.guesses.slice(0, -1), - error, - }; - } - - return state; -} - export interface KrunkBoardNotice { text: string; kind: 'error' | 'win' | 'info'; @@ -224,8 +190,10 @@ export function krunkWinMessage(moverShare: string): string { return krunkWinnerMessage('You', moverShare); } -function krunkAmountLabel(amount: string): string { - const labels = getCurrencyLabels(); +function krunkAmountLabel( + amount: string, + labels: CurrencyLabels = DEFAULT_CURRENCY_LABELS, +): string { const mojos = BigInt(amount); if (mojos < 1_000_000n) return `${mojos} ${labels.mojo}`; const TRILLION = 1_000_000_000_000n; @@ -274,136 +242,37 @@ function finishedKrunkState( export function useKrunkHand( handSource: GameHandSource, - _gameId: string, + gameId: string, iStarted: boolean, - gameplayEvent$: Observable, - onTurnChanged: (isMyTurn: boolean) => void, active = true, - initialPersistedState?: Readonly, ): UseKrunkHandResult { const interactive = handSource.interactionMode === 'live' && active; // Channel-level convention: iStarted=true → I'm second mover in // every game. Krunk's first mover is alice (the committer), so the // channel initiator plays bob and the receiver plays alice. const role: KrunkRole = iStarted ? 'bob' : 'alice'; + const gameState = krunkGameStateFromPersisted(gameHandState(handSource), gameId, role); - const [initialState] = useState(() => - krunkGameStateFromPersisted(initialPersistedState, _gameId, role), - ); - const [gs, setGs] = useState(initialState); - - const gsRef = useRef(gs); + const gameStateRef = useRef(gameState); const handSourceRef = useRef(handSource); - const gameIdRef = useRef(_gameId); - const handFinishedRef = useRef(false); + const gameIdRef = useRef(gameId); const activeRef = useRef(interactive); - gsRef.current = gs; + gameStateRef.current = gameState; handSourceRef.current = handSource; - gameIdRef.current = _gameId; + gameIdRef.current = gameId; activeRef.current = interactive; - useEffect(() => { - if (!_gameId) return; - if (!interactive) { - handFinishedRef.current = true; - return; - } - // Clear a stale finished latch if the hand is live again and we have not - // actually reached Terminal (guards against transient active=false gaps). - if (gsRef.current.handler !== KrunkHandler.Terminal) { - handFinishedRef.current = false; - } - }, [_gameId, interactive]); - - const projectState = useCallback( - (next: KrunkGameState) => { - gsRef.current = next; - setGs(next); - onTurnChanged(next.myTurn); - }, - [onTurnChanged], - ); - - const transition = useCallback( - (next: KrunkGameState) => { - const controller = requireLiveGameHandSource(handSourceRef.current); - if (gameIdRef.current) { - if (!controller.transitionFeatureState('krunk', gameIdRef.current, next)) { - return false; - } - } - projectState(next); - return true; - }, - [projectState], - ); - - const commitLocalAction = useCallback( - (next: KrunkGameState, command: LocalGameCommand): void => { - requireLiveGameHandSource(handSourceRef.current).commitLocalGameAction({ - gameType: 'krunk', - id: gameIdRef.current, - state: next, - command, - }); - projectState(next); - }, - [projectState], - ); - - const finishGame = useCallback( - ( - revealedWord: string | null, - lastClue: KrunkGuess['clue'] | null, - moverShare: string | null = null, - ) => { - const committed = transition( - finishedKrunkState(gsRef.current, revealedWord, lastClue, moverShare), - ); - if (committed) handFinishedRef.current = true; - return committed; - }, - [transition], - ); - - // ── OpponentMoved handling ── - useEffect(() => { - if (!interactive) return; - const sub = gameplayEvent$.subscribe({ - next: (evt: GameplayEvent) => { - if ('OpponentMoved' in evt) { - const evtGameId = evt.OpponentMoved.gameId; - if (evtGameId && evtGameId !== gameIdRef.current) return; - if (handFinishedRef.current) return; - const next = reduceKrunkFeatureState(gsRef.current, { - type: 'opponent-moved', - readable: Uint8Array.from(evt.OpponentMoved.readable), - moverShare: evt.OpponentMoved.moverShare, - }); - if (next.handler === KrunkHandler.Terminal) handFinishedRef.current = true; - projectState(next); - } else if ('MoveRejected' in evt) { - if (evt.MoveRejected.gameId !== gameIdRef.current) return; - if (handFinishedRef.current) return; - const next = applyKrunkMoveRejected(gsRef.current, evt.MoveRejected); - if (next !== gsRef.current) { - transition(next); - } - } else if ('Settled' in evt) { - if (evt.Settled.gameId !== gameIdRef.current) return; - handFinishedRef.current = true; - projectState(reduceKrunkFeatureState(gsRef.current, { type: 'settled' })); - } else if ('GameError' in evt) { - if (evt.GameError.gameId !== gameIdRef.current) return; - if (!handFinishedRef.current) { - finishGame(gsRef.current.revealedWord, null); - } - } - }, - }); - return () => sub.unsubscribe(); - }, [gameplayEvent$, interactive, transition, finishGame, projectState]); + const commitLocalAction = useCallback((next: KrunkGameState, command: LocalGameCommand): void => { + const gameId = gameIdRef.current; + requireLiveGameHandSource(handSourceRef.current).dispatch( + command.type === 'make-move' + ? { type: 'make-move', gameId, readable: command.readable, state: next } + : command.type === 'accept-settlement' + ? { type: 'accept-settlement', gameId, state: next } + : { type: 'cheat', gameId, moverShare: command.moverShare, state: next }, + ); + }, []); // ── Auto-play ── // Alice's `krunk_alice_handler_clue` decides internally whether to @@ -413,35 +282,35 @@ export function useKrunkHand( if (!interactive) return; if ( !activeRef.current || - gs.role !== 'alice' || - gs.handler !== KrunkHandler.AliceClue || - !gs.myTurn + gameState.role !== 'alice' || + gameState.handler !== KrunkHandler.AliceClue || + !gameState.myTurn ) return; const gid = gameIdRef.current; if (!activeRef.current || !gid) return; - const latest = gs.guesses[gs.guesses.length - 1]; + const latest = gameState.guesses[gameState.guesses.length - 1]; const isReveal = - !!latest && (latest.clue.every((v) => v === 2n) || gs.guesses.length >= MAX_GUESSES); + !!latest && (latest.clue.every((v) => v === 2n) || gameState.guesses.length >= MAX_GUESSES); const next = isReveal - ? finishedKrunkState(gs, gs.secretWord, latest.clue) - : { ...gs, handler: KrunkHandler.AliceWaiting, myTurn: false }; + ? finishedKrunkState(gameState, gameState.secretWord, latest.clue) + : { ...gameState, handler: KrunkHandler.AliceWaiting, myTurn: false }; commitLocalAction(next, { type: 'make-move', readable: null }); - if (isReveal) handFinishedRef.current = true; - }, [gs, interactive, commitLocalAction]); + }, [gameState, interactive, commitLocalAction]); const setSecretWord = useCallback( (word: string) => { - requireLiveGameHandSource(handSourceRef.current); + if (!activeRef.current) return; const gid = gameIdRef.current; - const cur = gsRef.current; - if (!activeRef.current || !gid) return; + const cur = gameStateRef.current; + if (!gid) return; if (cur.role !== 'alice' || cur.handler !== KrunkHandler.WaitingCommit) return; const normalised = word.trim().toUpperCase(); if (!/^[A-Z]{5}$/.test(normalised)) { console.warn('[krunk] secret word must be 5 letters'); return; } + requireLiveGameHandSource(handSourceRef.current); const next = { ...cur, secretWord: normalised, @@ -459,16 +328,17 @@ export function useKrunkHand( const submitGuess = useCallback( (word: string) => { - requireLiveGameHandSource(handSourceRef.current); + if (!activeRef.current) return; const gid = gameIdRef.current; - const cur = gsRef.current; - if (!activeRef.current || !gid) return; + const cur = gameStateRef.current; + if (!gid) return; if (cur.role !== 'bob' || cur.handler !== KrunkHandler.BobGuess) return; const normalised = word.trim().toUpperCase(); if (!/^[A-Z]{5}$/.test(normalised)) { console.warn('[krunk] guess must be 5 letters'); return; } + requireLiveGameHandSource(handSourceRef.current); const next = { ...cur, guesses: [ @@ -490,7 +360,7 @@ export function useKrunkHand( ); return { - gameState: gs, + gameState, setSecretWord, submitGuess, }; diff --git a/games/registry.json b/games/registry.json new file mode 100644 index 000000000..984a23209 --- /dev/null +++ b/games/registry.json @@ -0,0 +1,4 @@ +{ + "production": ["calpoker", "spacepoker", "krunk"], + "test": ["debug"] +} diff --git a/games/spacepoker/clsp/factory.clsp b/games/spacepoker/clsp/factory.clsp new file mode 100644 index 000000000..7c93af6ce --- /dev/null +++ b/games/spacepoker/clsp/factory.clsp @@ -0,0 +1,5 @@ +(include *standard-cl-23*) + +(import games.spacepoker.clsp.spacepoker_generate exposing spacepoker_factory) + +(export spacepoker_factory) diff --git a/clsp/games/spacepoker/onchain/begin_round.clsp b/games/spacepoker/clsp/onchain/begin_round.clsp similarity index 100% rename from clsp/games/spacepoker/onchain/begin_round.clsp rename to games/spacepoker/clsp/onchain/begin_round.clsp diff --git a/clsp/games/spacepoker/onchain/commitA.clsp b/games/spacepoker/clsp/onchain/commitA.clsp similarity index 86% rename from clsp/games/spacepoker/onchain/commitA.clsp rename to games/spacepoker/clsp/onchain/commitA.clsp index c665dce81..eb15919d2 100644 --- a/clsp/games/spacepoker/onchain/commitA.clsp +++ b/games/spacepoker/clsp/onchain/commitA.clsp @@ -1,6 +1,6 @@ (include *standard-cl-23*) -(import games.spacepoker.onchain.commitB exposing (program_hash as commitB_hash)) +(import games.spacepoker.clsp.onchain.commitB exposing (program_hash as commitB_hash)) (import games.game_codes) (import std.and) (import std.if_any_fail) diff --git a/clsp/games/spacepoker/onchain/commitB.clsp b/games/spacepoker/clsp/onchain/commitB.clsp similarity index 79% rename from clsp/games/spacepoker/onchain/commitB.clsp rename to games/spacepoker/clsp/onchain/commitB.clsp index f29ff1ec5..e37910780 100644 --- a/clsp/games/spacepoker/onchain/commitB.clsp +++ b/games/spacepoker/clsp/onchain/commitB.clsp @@ -1,7 +1,7 @@ (include *standard-cl-23*) -(import games.spacepoker.onchain.begin_round exposing (program_hash as begin_hash)) -(import games.spacepoker.onchain.mid_round exposing (program_hash as mid_hash)) +(import games.spacepoker.clsp.onchain.begin_round exposing (program_hash as begin_hash)) +(import games.spacepoker.clsp.onchain.mid_round exposing (program_hash as mid_hash)) (import games.game_codes) (import std.and) (import std.if_any_fail) diff --git a/clsp/games/spacepoker/onchain/end.clsp b/games/spacepoker/clsp/onchain/end.clsp similarity index 98% rename from clsp/games/spacepoker/onchain/end.clsp rename to games/spacepoker/clsp/onchain/end.clsp index 4cf9cb65a..f7d7b0b1e 100644 --- a/clsp/games/spacepoker/onchain/end.clsp +++ b/games/spacepoker/clsp/onchain/end.clsp @@ -1,6 +1,6 @@ (include *standard-cl-23*) -(import games.spacepoker.onchain.space_hand_eval exposing space_hand_eval) +(import games.spacepoker.clsp.onchain.space_hand_eval exposing space_hand_eval) (import std.and) (import std.if_any_fail) (import std.assert) diff --git a/clsp/games/spacepoker/onchain/mid_round.clsp b/games/spacepoker/clsp/onchain/mid_round.clsp similarity index 93% rename from clsp/games/spacepoker/onchain/mid_round.clsp rename to games/spacepoker/clsp/onchain/mid_round.clsp index 5af350db6..f37257e98 100644 --- a/clsp/games/spacepoker/onchain/mid_round.clsp +++ b/games/spacepoker/clsp/onchain/mid_round.clsp @@ -1,7 +1,7 @@ (include *standard-cl-23*) -(import games.spacepoker.onchain.begin_round exposing (program_hash as begin_hash)) -(import games.spacepoker.onchain.end exposing (program_hash as end_hash)) +(import games.spacepoker.clsp.onchain.begin_round exposing (program_hash as begin_hash)) +(import games.spacepoker.clsp.onchain.end exposing (program_hash as end_hash)) (import games.game_codes) (import std.and) (import std.if_any_fail) diff --git a/clsp/games/spacepoker/onchain/space_hand_eval.clinc b/games/spacepoker/clsp/onchain/space_hand_eval.clinc similarity index 100% rename from clsp/games/spacepoker/onchain/space_hand_eval.clinc rename to games/spacepoker/clsp/onchain/space_hand_eval.clinc diff --git a/clsp/games/spacepoker/space_hand_calc.clinc b/games/spacepoker/clsp/space_hand_calc.clinc similarity index 95% rename from clsp/games/spacepoker/space_hand_calc.clinc rename to games/spacepoker/clsp/space_hand_calc.clinc index 48f555b51..955412efc 100644 --- a/clsp/games/spacepoker/space_hand_calc.clinc +++ b/games/spacepoker/clsp/space_hand_calc.clinc @@ -7,7 +7,7 @@ (import std.max) (import std.deep_compare) (import std.relops) -(import games.spacepoker.onchain.space_hand_eval exposing space_hand_eval) +(import games.spacepoker.clsp.onchain.space_hand_eval exposing space_hand_eval) ; Enumerate all C(n,5) subsets from a list of cards. ; Returns list of (selected_cards bitfield count) triples. diff --git a/clsp/games/spacepoker/spacepoker_generate.clinc b/games/spacepoker/clsp/spacepoker_generate.clinc similarity index 97% rename from clsp/games/spacepoker/spacepoker_generate.clinc rename to games/spacepoker/clsp/spacepoker_generate.clinc index 89fbccf77..ae9bc557d 100644 --- a/clsp/games/spacepoker/spacepoker_generate.clinc +++ b/games/spacepoker/clsp/spacepoker_generate.clinc @@ -1,11 +1,11 @@ (include *standard-cl-23*) -(import games.spacepoker.onchain.commitA exposing (program as val_commitA) (program_hash as commitA_hash)) -(import games.spacepoker.onchain.commitB exposing (program as val_commitB) (program_hash as commitB_hash)) -(import games.spacepoker.onchain.begin_round exposing (program as val_begin) (program_hash as begin_hash)) -(import games.spacepoker.onchain.mid_round exposing (program as val_mid) (program_hash as mid_hash)) -(import games.spacepoker.onchain.end exposing (program as val_end) (program_hash as end_hash)) -(import games.spacepoker.space_hand_calc exposing space_hand_calc) +(import games.spacepoker.clsp.onchain.commitA exposing (program as val_commitA) (program_hash as commitA_hash)) +(import games.spacepoker.clsp.onchain.commitB exposing (program as val_commitB) (program_hash as commitB_hash)) +(import games.spacepoker.clsp.onchain.begin_round exposing (program as val_begin) (program_hash as begin_hash)) +(import games.spacepoker.clsp.onchain.mid_round exposing (program as val_mid) (program_hash as mid_hash)) +(import games.spacepoker.clsp.onchain.end exposing (program as val_end) (program_hash as end_hash)) +(import games.spacepoker.clsp.space_hand_calc exposing space_hand_calc) (import std.li) (import std.curry) diff --git a/games/spacepoker/rust/mod.rs b/games/spacepoker/rust/mod.rs new file mode 100644 index 000000000..1aad01d99 --- /dev/null +++ b/games/spacepoker/rust/mod.rs @@ -0,0 +1,23 @@ +use clvm_traits::ToClvm; + +use crate::common::load_clvm::read_hex_puzzle; +use crate::common::types::{AllocEncoder, Error, IntoErr, Program}; +use crate::session_phases::types::GameFactory; + +pub const FACTORY_HEX: &str = "games/spacepoker/clsp/factory_spacepoker_factory.hex"; + +pub fn prepared_factory(allocator: &mut AllocEncoder) -> Result { + let factory = read_hex_puzzle(allocator, FACTORY_HEX)?; + Ok(GameFactory { + program: Some(factory.to_program()), + }) +} + +/// Canonical probe: 1-mojo stake, 1-mojo unit, sender goes first. +pub fn probe_parameters(allocator: &mut AllocEncoder) -> Result { + let node = (1u64, (1u64, (1u64, ()))).to_clvm(allocator).into_gen()?; + Program::from_nodeptr(allocator, node) +} + +#[cfg(test)] +pub mod tests; diff --git a/src/tests/spacepoker_handlers.rs b/games/spacepoker/rust/tests/handlers.rs similarity index 99% rename from src/tests/spacepoker_handlers.rs rename to games/spacepoker/rust/tests/handlers.rs index 7284ed98d..1b01d8486 100644 --- a/src/tests/spacepoker_handlers.rs +++ b/games/spacepoker/rust/tests/handlers.rs @@ -258,7 +258,7 @@ struct GameSetup { fn setup_game(allocator: &mut AllocEncoder) -> GameSetup { let factory = read_hex_puzzle( allocator, - "clsp/games/spacepoker/spacepoker_include_spacepoker_factory.hex", + "games/spacepoker/clsp/factory_spacepoker_factory.hex", ) .expect("load factory"); let factory_clvm = factory.to_clvm(allocator).unwrap(); @@ -658,7 +658,7 @@ fn test_spacepoker_setup_game() { fn factory_succeeds(allocator: &mut AllocEncoder, args: NodePtr) -> bool { let factory = read_hex_puzzle( allocator, - "clsp/games/spacepoker/spacepoker_include_spacepoker_factory.hex", + "games/spacepoker/clsp/factory_spacepoker_factory.hex", ) .expect("load factory"); let factory_clvm = factory.to_clvm(allocator).unwrap(); @@ -1210,7 +1210,7 @@ fn run_end_validator_with_evidence( evidence: &[u8], ) -> MoveCode { use crate::common::types::Sha256tree; - let end_puzzle = read_hex_puzzle(allocator, "clsp/games/spacepoker/onchain/end.hex") + let end_puzzle = read_hex_puzzle(allocator, "games/spacepoker/clsp/onchain/end.hex") .expect("load end validator"); let end_hash_bytes = *end_puzzle.sha256tree(allocator).hash().bytes(); let end_hash_node = allocator.allocator().new_atom(&end_hash_bytes).unwrap(); diff --git a/games/spacepoker/rust/tests/mod.rs b/games/spacepoker/rust/tests/mod.rs new file mode 100644 index 000000000..198c8e07c --- /dev/null +++ b/games/spacepoker/rust/tests/mod.rs @@ -0,0 +1,11 @@ +pub mod handlers; +pub mod sim; +pub mod validation; + +pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { + let mut funs = handlers::test_funs(); + funs.extend(validation::test_funs()); + #[cfg(feature = "sim-tests")] + funs.extend(sim::test_funs()); + funs +} diff --git a/src/test_support/spacepoker_sim.rs b/games/spacepoker/rust/tests/sim.rs similarity index 100% rename from src/test_support/spacepoker_sim.rs rename to games/spacepoker/rust/tests/sim.rs diff --git a/src/tests/spacepoker_validation.rs b/games/spacepoker/rust/tests/validation.rs similarity index 99% rename from src/tests/spacepoker_validation.rs rename to games/spacepoker/rust/tests/validation.rs index 30399917d..24a7a10a8 100644 --- a/src/tests/spacepoker_validation.rs +++ b/games/spacepoker/rust/tests/validation.rs @@ -31,7 +31,7 @@ fn load_validators(allocator: &mut AllocEncoder) -> ValidatorLibrary { let mut hashes = Vec::new(); let mut by_hash = std::collections::HashMap::new(); for name in &VALIDATOR_NAMES { - let path = format!("clsp/games/spacepoker/onchain/{name}.hex"); + let path = format!("games/spacepoker/clsp/onchain/{name}.hex"); let puzzle = read_hex_puzzle(allocator, &path) .unwrap_or_else(|e| panic!("failed to load {path}: {e:?}")); let ph = puzzle.sha256tree(allocator); diff --git a/front-end/src/features/spacePoker/SpacePoker.tsx b/games/spacepoker/ui/SpacePoker.tsx similarity index 76% rename from front-end/src/features/spacePoker/SpacePoker.tsx rename to games/spacepoker/ui/SpacePoker.tsx index bcae620c2..3818bd1c0 100644 --- a/front-end/src/features/spacePoker/SpacePoker.tsx +++ b/games/spacepoker/ui/SpacePoker.tsx @@ -1,14 +1,7 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { Observable } from 'rxjs'; -import { - requireLiveGameHandSource, - type GameHandSource, - useInitialGameHandState, -} from '../../lib/gameMount'; -import type { GameTerminalModel } from '../../lib/session/types'; -import { useCheatNerfKeys } from '../../hooks/useCheatNerfKeys'; -import type { GameplayEvent } from '../../hooks/useGameSession'; -import { getCurrencyLabels } from '../../constants/currency'; +import { useEffect, useRef, useState } from 'react'; +import { gameHandState, type GameHandSource } from '../../host'; +import type { GameTerminalModel } from '../../host'; +import { useCheatKeys, useGameHost } from '../../host/ui'; import { describeSpacePokerHand, formatSpacepokerHandLog } from './handPresentation'; import { SpacePokerActionControls } from './SpacePokerActionControls'; import { SpacePokerHandHistory, SpacePokerTable } from './SpacePokerTable'; @@ -29,12 +22,9 @@ import { export interface SpacePokerProps { handSource: GameHandSource; gameId: string; - iStarted: boolean; - gameplayEvent$: Observable; betSize: string; unitSizeMojos: string; - onTurnChanged: (isMyTurn: boolean) => void; - onGameLog: (lines: string[]) => void; + onGameLog?: (lines: string[]) => void; myName?: string; opponentName?: string; terminal: GameTerminalModel; @@ -43,11 +33,8 @@ export interface SpacePokerProps { export default function SpacePoker({ handSource, gameId, - iStarted, - gameplayEvent$, betSize, unitSizeMojos, - onTurnChanged, onGameLog, myName, opponentName, @@ -56,28 +43,14 @@ export default function SpacePoker({ const interactive = handSource.interactionMode === 'live'; const betSizeValue = BigInt(betSize); const unitSizeMojosValue = BigInt(unitSizeMojos); - const initialPersistedState = useInitialGameHandState(handSource); - const sp = useSpacepokerHand( - handSource, - gameId, - iStarted, - gameplayEvent$, - betSizeValue, - unitSizeMojosValue, - onTurnChanged, - terminal, - initialPersistedState ?? undefined, - ); + const sp = useSpacepokerHand(handSource, gameId, betSizeValue, unitSizeMojosValue, terminal); const { handler, myTurn, N } = sp.gameState; - const spCurrency = getCurrencyLabels(); + const { currencyLabels: spCurrency, formatAmount } = useGameHost(); - const handleNerf = useCallback(() => { - requireLiveGameHandSource(handSource).nerf(); - }, [handSource]); - useCheatNerfKeys(sp.handleCheat, handleNerf, interactive); + useCheatKeys(sp.handleCheat, interactive); const [alreadyTerminalAtMount] = useState(() => { - const handState = initialPersistedState; + const handState = gameHandState(handSource); if (!handState || handState.gameType !== 'spacepoker') return false; const state = handState.state as SpacepokerHandState | undefined; return state?.terminalState != null && state.terminalState !== 'none'; @@ -86,8 +59,8 @@ export default function SpacePoker({ useEffect(() => { if (sp.terminalState === 'none' || gameLogFiredRef.current || !sp.playerHoleCards) return; gameLogFiredRef.current = true; - const stackSize = sp.betUnit > 0n ? betSizeValue / sp.betUnit : 0n; - onGameLog( + const stackSize = sp.betUnit > 0n ? betSizeValue / 2n / sp.betUnit : 0n; + onGameLog?.( formatSpacepokerHandLog( sp.playerHoleCards, sp.playerBoost, @@ -100,6 +73,7 @@ export default function SpacePoker({ sp.coinTossIOpen, sp.betUnit, stackSize, + formatAmount, ), ); }, [ @@ -115,6 +89,7 @@ export default function SpacePoker({ sp.betUnit, betSizeValue, onGameLog, + formatAmount, ]); const inBetting = handler === SpHandler.BeginRound || handler === SpHandler.MidRound; @@ -194,21 +169,6 @@ export default function SpacePoker({ />
- {sp.terminalRecovery && ( -
-

- Final {sp.terminalRecovery} was not submitted. -

- -
- )} {footerStatus}

+ {sp.error && ( +

+ {sp.error.message} +

+ )}
diff --git a/front-end/src/features/spacePoker/SpacePokerActionControls.tsx b/games/spacepoker/ui/SpacePokerActionControls.tsx similarity index 100% rename from front-end/src/features/spacePoker/SpacePokerActionControls.tsx rename to games/spacepoker/ui/SpacePokerActionControls.tsx diff --git a/front-end/src/features/spacePoker/SpacePokerTable.tsx b/games/spacepoker/ui/SpacePokerTable.tsx similarity index 100% rename from front-end/src/features/spacePoker/SpacePokerTable.tsx rename to games/spacepoker/ui/SpacePokerTable.tsx diff --git a/front-end/src/features/spacePoker/handPresentation.ts b/games/spacepoker/ui/handPresentation.ts similarity index 98% rename from front-end/src/features/spacePoker/handPresentation.ts rename to games/spacepoker/ui/handPresentation.ts index 3f69b0b68..c51cb5992 100644 --- a/front-end/src/features/spacePoker/handPresentation.ts +++ b/games/spacepoker/ui/handPresentation.ts @@ -1,4 +1,4 @@ -import { formatAmount } from '../../util'; +import { defaultFormatAmount } from '../../host'; import type { SpHandEntry, SpOutcome, SpTerminalState } from './useSpacepokerHand'; const RANK_LABELS: Record = { @@ -141,6 +141,7 @@ export function formatSpacepokerHandLog( coinTossIOpen: boolean | null, betUnit: bigint, stackSize: bigint, + formatAmount: (mojos: bigint) => string = defaultFormatAmount, ): string[] { const weOpenFirst = coinTossIOpen === true; const posLabel = weOpenFirst ? '1st' : '2nd'; diff --git a/games/spacepoker/ui/handProposal.ts b/games/spacepoker/ui/handProposal.ts new file mode 100644 index 000000000..085fcf409 --- /dev/null +++ b/games/spacepoker/ui/handProposal.ts @@ -0,0 +1,146 @@ +import { + equalHandProposalBase, + type GameFeatureRegistration, + type HandProposal, +} from '../../host'; +import { reduceSpacepokerDurableState, spacepokerStateCodec, type SpacepokerHandState } from './serialize'; +import { + resolveSpacepokerUnitSize, + spacepokerFactoryParameters, + spacepokerTermsOf, + type SpacepokerFactoryParameters, +} from './unitSize'; + +export { + reduceSpacepokerDurableState, + reduceSpacepokerFeatureState, + reduceSpacepokerSettlementState, +} from './serialize'; + +export function validateSpacepokerHandProposal(handProposal: HandProposal): boolean { + const space = spacepokerTermsOf(handProposal); + return ( + space !== null && + space.myContribution === space.theirContribution && + space.myContribution > 0n && + space.gameTimeout > 0n && + resolveSpacepokerUnitSize({ terms: space }) !== null && + space.myContribution % space.unitSizeMojos === 0n + ); +} + +const registration: GameFeatureRegistration< + SpacepokerHandState, + SpacepokerHandState, + { unitSize: bigint; stackSize: bigint }, + SpacepokerFactoryParameters +> = { + gameType: 'spacepoker', + displayName: 'Space Poker', + stateCodec: spacepokerStateCodec, + factoryParameters: spacepokerFactoryParameters, + describeHandProposal(handProposal, { formatMojos }) { + const space = spacepokerTermsOf(handProposal); + if (!space) return `Stake ${formatMojos(handProposal.myContribution)} each`; + const stack = space.myContribution / space.unitSizeMojos; + return `Stake ${formatMojos(space.myContribution)} each · unit ${formatMojos(space.unitSizeMojos)} · stack ${String(stack)}`; + }, + handMembershipDescription: 'exactly one currentHandGameId', + validateHandMembership: (gameIds) => gameIds.length === 1, + decodeFeatureState: (value) => (spacepokerStateCodec.isState(value) ? value : null), + selectOutcome: (state) => + state.outcome + ? { my_win_outcome: state.outcome.result > 0n ? 'win' : state.outcome.result < 0n ? 'lose' : 'tie' } + : null, + lifecycle: { + proposalSenderGoesFirst: (iStarted) => !iStarted, + }, + draft: { + default: () => ({ unitSize: 1n, stackSize: 10n }), + fromHandProposal: (handProposal) => { + const unitSize = spacepokerTermsOf(handProposal)?.unitSizeMojos ?? 1n; + return { + unitSize, + stackSize: unitSize > 0n ? handProposal.myContribution / unitSize : 10n, + }; + }, + update: (current, update) => ({ ...current, ...update }), + toHandProposal(draft, gameTimeout) { + if (draft.stackSize > BigInt(Number.MAX_SAFE_INTEGER) || draft.stackSize <= 0n) return null; + const amount = draft.unitSize * draft.stackSize; + const handProposal = { + gameType: 'spacepoker', + myContribution: amount, + theirContribution: amount, + gameTimeout, + unitSizeMojos: draft.unitSize, + }; + return validateSpacepokerHandProposal(handProposal) ? handProposal : null; + }, + }, + toFactoryParameters(handProposal, iStarted) { + const betUnit = resolveSpacepokerUnitSize({ terms: handProposal }); + if (!betUnit || !this.validateHandProposal(handProposal)) { + throw new Error('Space Poker proposal requires a valid positive unit size'); + } + return { + perPlayerStake: handProposal.myContribution, + betUnit, + senderGoesFirst: this.lifecycle.proposalSenderGoesFirst(iStarted), + }; + }, + decodeHandProposal(base, params, context) { + if ( + params.perPlayerStake !== base.myContribution || + params.senderGoesFirst !== context.expectedSenderGoesFirst + ) { + return null; + } + const handProposal = { + gameType: 'spacepoker', + ...base, + unitSizeMojos: params.betUnit, + }; + return validateSpacepokerHandProposal(handProposal) ? handProposal : null; + }, + validateHandProposal: validateSpacepokerHandProposal, + handProposalsEqual: (a, b) => { + const left = spacepokerTermsOf(a); + const right = spacepokerTermsOf(b); + return ( + left !== null && + right !== null && + equalHandProposalBase(left, right) && + left.unitSizeMojos === right.unitSizeMojos + ); + }, + persistence: { + encodeExtras: (handProposal) => { + const space = spacepokerTermsOf(handProposal); + return space === null ? {} : { spacepoker_unit_size: space.unitSizeMojos.toString() }; + }, + decodeExtras(base, extras) { + const raw = extras.spacepoker_unit_size; + if (raw === undefined) return null; + try { + const unitSizeMojos = BigInt(raw); + const handProposal = { gameType: 'spacepoker', ...base, unitSizeMojos }; + return validateSpacepokerHandProposal(handProposal) ? handProposal : null; + } catch { + return null; + } + }, + }, + durableState: { + initialize(current, input) { + return reduceSpacepokerDurableState(current, input)!; + }, + reduceInput(current, input) { + return reduceSpacepokerDurableState(current, input)!; + }, + applyFeatureState: (_current, _gameId, state) => state, + }, +}; + +export const spacepokerRegistration = registration; +export default registration; diff --git a/games/spacepoker/ui/handProposalForm.tsx b/games/spacepoker/ui/handProposalForm.tsx new file mode 100644 index 000000000..e35685bd3 --- /dev/null +++ b/games/spacepoker/ui/handProposalForm.tsx @@ -0,0 +1,55 @@ +import { AmountInput, useGameHost } from '../../host/ui'; +import type { HandProposalFormProps } from '../../host'; + +export function HandProposalForm({ + draft, + disabled, + maxPerHandMojos, + onChange, + onSubmit, +}: HandProposalFormProps<{ unitSize: bigint; stackSize: bigint }>) { + const { formatMojos } = useGameHost(); + const betSize = draft.unitSize * draft.stackSize; + const maxUnitSize = + maxPerHandMojos != null && draft.stackSize > 0n ? maxPerHandMojos / draft.stackSize : null; + return ( + <> + onChange({ unitSize })} + maxMojos={maxUnitSize} + onUseMax={ + maxUnitSize != null && maxUnitSize > 0n + ? () => onChange({ unitSize: maxUnitSize }) + : undefined + } + disabled={disabled} + label="Unit size" + exceedsLabel="Exceeds available reserve." + onKeyDown={(event) => { + if (event.key === 'Enter') onSubmit(); + }} + /> +
+ + { + const next = event.target.value.replace(/[^0-9]/g, ''); + onChange({ stackSize: BigInt(next || '0') }); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') onSubmit(); + }} + /> +
+
+ Per-player stake: {formatMojos(betSize)} · Total game size: {formatMojos(betSize * 2n)} +
+ + ); +} diff --git a/games/spacepoker/ui/play.tsx b/games/spacepoker/ui/play.tsx new file mode 100644 index 000000000..5f8ebdeae --- /dev/null +++ b/games/spacepoker/ui/play.tsx @@ -0,0 +1,86 @@ +import { lazy, useCallback } from 'react'; +import { + EMPTY_GAME_TERMINAL_MODEL, + gameHandState, + gameHandSourceFromMountView, + type GameHandSource, + type GameMountRegistration, + type GameTerminalModel, +} from '../../host'; +import { useGameHost } from '../../host/ui'; +import { spacepokerStateCodec } from './serialize'; + +const SpacePoker = lazy(() => import('./SpacePoker')); + +export interface SpacepokerLiveMountProps { + handSource: GameHandSource; + gameId: string; + betSize: bigint; + appendGameLog?: (line: string) => void; + myName?: string; + opponentName?: string; + terminal: GameTerminalModel; +} + +export function SpacepokerLiveMount(props: SpacepokerLiveMountProps) { + const { + handSource, + gameId, + betSize, + appendGameLog, + myName, + opponentName, + terminal, + } = props; + const { formatAmount } = useGameHost(); + const handState = spacepokerStateCodec.decode(gameHandState(handSource)); + if (!handState) { + throw new Error('Space Poker mount requires initialized durable game state'); + } + const unitSizeMojosValue = handState.unitSizeMojos; + const stackSize = betSize / 2n / unitSizeMojosValue; + const handleGameLog = useCallback( + (lines: string[]) => { + if (!appendGameLog) return; + appendGameLog(`Space Poker ${stackSize} (${formatAmount(unitSizeMojosValue)})`); + lines.forEach(appendGameLog); + appendGameLog(''); + }, + [appendGameLog, formatAmount, stackSize, unitSizeMojosValue], + ); + + return ( + + ); +} + +export const play: GameMountRegistration = { + render(view) { + const gameId = + view.activeIds[0] ?? view.lastDisplayedId ?? view.currentHandIds[0] ?? 'finished'; + const amount = view.instances[gameId]?.amount; + if (amount === undefined) { + throw new Error(`Space Poker is missing the accepted amount for game ${gameId}`); + } + return ( + + ); + }, +}; diff --git a/front-end/src/features/spacePoker/adapter.ts b/games/spacepoker/ui/serialize.ts similarity index 56% rename from front-end/src/features/spacePoker/adapter.ts rename to games/spacepoker/ui/serialize.ts index 0bf46f825..dccb2e2dd 100644 --- a/front-end/src/features/spacePoker/adapter.ts +++ b/games/spacepoker/ui/serialize.ts @@ -1,14 +1,212 @@ import { Program } from 'clvm-lib'; import { - equalBaseTerms, - reduceGameStateSnapshot, - type DurableGameStateEvent, - type GameFeatureRegistration, - type TermsFor, -} from '../../lib/gameAdapter'; -import { isForfeitOutcome, type SettlementOutcome } from '../../lib/settlement'; -import { spacepokerStateCodec, type SpacepokerHandState, type SpHandEntry } from './stateCodec'; -import { resolveSpacepokerUnitSize } from './unitSize'; + defineGameStateCodec, + isForfeitOutcome, + type GameInput, + type SettlementOutcome, +} from '../../host'; + +export type SpacepokerDisplayMode = 'xch' | 'mojos' | 'units'; +export interface SpacepokerError { + tag: string; + message: string; +} +export type SpHandler = 0n | 1n | 2n | 3n | 4n | 5n | 6n; +export interface SpGameState { + handler: SpHandler; + myTurn: boolean; + N: bigint; +} +export interface SpHandEntry { + player: 'you' | 'opponent'; + action: 'check' | 'raise' | 'call' | 'fold' | 'concede' | 'reveal' | 'failed'; + units?: bigint; + endsStreet?: boolean; +} +export interface SpOutcome { + result: bigint; + playerHandCards: bigint[]; + playerHandEval: bigint[]; + opponentHandCards: bigint[] | null; + opponentHandEval: bigint[] | null; +} +export type SpTerminalState = + | 'none' + | 'settled' + | 'revealed' + | 'conceded-by-you' + | 'conceded-by-opponent' + | 'folded-by-you' + | 'folded-by-opponent' + | 'won-by-opponent-failure'; + +export interface SpacepokerHandState { + gameState: SpGameState; + playerHoleCards: [bigint, bigint] | null; + playerBoost: boolean; + opponentHoleCards: [bigint, bigint] | null; + opponentBoost: boolean | null; + communityCards: (bigint | null)[]; + halfPot: bigint; + lastRaise: bigint; + iRaisedLast: boolean; + handHistory: SpHandEntry[]; + outcome: SpOutcome | null; + terminalState: SpTerminalState; + coinTossIOpen: boolean | null; + unitSizeMojos: bigint; + displayMode: SpacepokerDisplayMode; + error: SpacepokerError | null; +} + +const HANDLERS = new Set([0n, 1n, 2n, 3n, 4n, 5n, 6n]); +const TERMINALS = new Set([ + 'none', + 'settled', + 'revealed', + 'conceded-by-you', + 'conceded-by-opponent', + 'folded-by-you', + 'folded-by-opponent', + 'won-by-opponent-failure', +]); +const DISPLAY_MODES = new Set(['xch', 'mojos', 'units']); +const ACTIONS = new Set(['check', 'raise', 'call', 'fold', 'concede', 'reveal', 'failed']); + +function isCardPair(value: unknown): value is [bigint, bigint] { + return ( + Array.isArray(value) && + value.length === 2 && + value.every((card) => typeof card === 'bigint' && card >= 0n && card < 52n) + ); +} + +function isGameState(value: unknown): value is SpGameState { + if (typeof value !== 'object' || value === null) return false; + const state = value as Partial; + if ( + typeof state.handler !== 'bigint' || + !HANDLERS.has(state.handler) || + typeof state.myTurn !== 'boolean' || + typeof state.N !== 'bigint' + ) { + return false; + } + const validN = + (state.handler <= 1n && state.N === 4n) || + ((state.handler === 2n || state.handler === 3n) && state.N >= 1n && state.N <= 4n) || + (state.handler === 4n && state.N === 1n) || + (state.handler === 5n && (state.N === 0n || state.N === 1n)) || + (state.handler === 6n && state.N >= 1n && state.N <= 4n); + return validN && (!isTerminalHandler(state.handler) || state.myTurn === false); +} + +function isTerminalHandler(handler: bigint): boolean { + return handler === 5n || handler === 6n; +} + +function isHistory(value: unknown): value is SpHandEntry[] { + return ( + Array.isArray(value) && + value.every((entry) => { + if (typeof entry !== 'object' || entry === null) return false; + const item = entry as Partial; + const hasUnits = typeof item.units === 'bigint' && item.units > 0n; + return ( + (item.player === 'you' || item.player === 'opponent') && + typeof item.action === 'string' && + ACTIONS.has(item.action) && + (item.action === 'raise' ? hasUnits : item.units === undefined) && + (item.endsStreet === undefined || + (item.action === 'check' && typeof item.endsStreet === 'boolean')) + ); + }) + ); +} + +function isOutcome(value: unknown): value is SpOutcome { + if (typeof value !== 'object' || value === null) return false; + const outcome = value as Partial; + const bigints = (cards: unknown) => + Array.isArray(cards) && cards.every((card) => typeof card === 'bigint'); + const cards = (value: unknown) => + Array.isArray(value) && + value.every((card) => typeof card === 'bigint' && card >= 0n && card < 52n); + return ( + typeof outcome.result === 'bigint' && + cards(outcome.playerHandCards) && + bigints(outcome.playerHandEval) && + (outcome.opponentHandCards === null || cards(outcome.opponentHandCards)) && + (outcome.opponentHandEval === null || bigints(outcome.opponentHandEval)) && + (outcome.opponentHandCards === null) === (outcome.opponentHandEval === null) + ); +} + +function isSpacepokerError(value: unknown): value is SpacepokerError { + if (typeof value !== 'object' || value === null) return false; + const error = value as Partial; + return ( + Object.keys(value).length === 2 && + typeof error.tag === 'string' && + /^[a-z][a-z0-9_]*$/.test(error.tag) && + typeof error.message === 'string' && + error.message.length > 0 + ); +} + +function isSpacepokerHandState(value: unknown): value is SpacepokerHandState { + if (typeof value !== 'object' || value === null) return false; + const state = value as Partial; + if ( + !isGameState(state.gameState) || + (state.playerHoleCards !== null && !isCardPair(state.playerHoleCards)) || + (state.opponentHoleCards !== null && !isCardPair(state.opponentHoleCards)) || + !Array.isArray(state.communityCards) || + state.communityCards.length !== 5 || + !state.communityCards.every( + (card) => card === null || (typeof card === 'bigint' && card >= 0n && card < 52n), + ) + ) { + return false; + } + if (typeof state.terminalState !== 'string' || !TERMINALS.has(state.terminalState)) return false; + const terminalHandlerMatches = + state.terminalState === 'none' || + (state.terminalState === 'settled' && state.gameState.handler === 6n) || + (state.terminalState === 'revealed' && state.gameState.handler === 5n) || + ((state.terminalState === 'conceded-by-you' || + state.terminalState === 'conceded-by-opponent') && + state.gameState.handler === 5n) || + ((state.terminalState === 'folded-by-you' || state.terminalState === 'folded-by-opponent') && + state.gameState.handler === 6n) || + (state.terminalState === 'won-by-opponent-failure' && state.gameState.handler === 6n); + if (!terminalHandlerMatches) return false; + if (state.terminalState === 'revealed' && state.outcome === null) return false; + return ( + typeof state.playerBoost === 'boolean' && + (state.opponentBoost === null || typeof state.opponentBoost === 'boolean') && + typeof state.halfPot === 'bigint' && + state.halfPot >= 0n && + typeof state.lastRaise === 'bigint' && + state.lastRaise >= 0n && + typeof state.iRaisedLast === 'boolean' && + isHistory(state.handHistory) && + (state.outcome === null || isOutcome(state.outcome)) && + (state.coinTossIOpen === null || typeof state.coinTossIOpen === 'boolean') && + typeof state.unitSizeMojos === 'bigint' && + state.unitSizeMojos > 0n && + typeof state.displayMode === 'string' && + DISPLAY_MODES.has(state.displayMode) && + (state.error === null || isSpacepokerError(state.error)) + ); +} + +export const spacepokerStateCodec = defineGameStateCodec({ + gameType: 'spacepoker', + version: 4n, + canRemountFinished: true, + isState: isSpacepokerHandState, +}); function initialState(isMyTurn: boolean, unitSizeMojos: bigint): SpacepokerHandState { return { @@ -24,11 +222,10 @@ function initialState(isMyTurn: boolean, unitSizeMojos: bigint): SpacepokerHandS handHistory: [], outcome: null, terminalState: 'none', - terminalRecovery: null, - pendingTerminalAction: null, coinTossIOpen: null, unitSizeMojos, displayMode: unitSizeMojos >= 1_000_000n ? 'xch' : 'mojos', + error: null, }; } @@ -84,7 +281,6 @@ function reduceSpacepokerSettlementStateCore( ...current, gameState: { handler: 5n, myTurn: false, N: 1n }, terminalState: 'conceded-by-you', - terminalRecovery: null, }); } if (outcome === 'opponent_timed_out' && current.terminalState === 'none') { @@ -94,7 +290,6 @@ function reduceSpacepokerSettlementStateCore( ...current, gameState: { handler: 5n, myTurn: false, N: 1n }, terminalState: 'conceded-by-opponent', - terminalRecovery: null, }, { player: 'opponent', action: 'concede' }, ); @@ -108,7 +303,6 @@ function reduceSpacepokerSettlementStateCore( N: current.gameState.N >= 1n ? current.gameState.N : 1n, }, terminalState: 'won-by-opponent-failure', - terminalRecovery: null, }, { player: 'opponent', action: 'failed' }, ); @@ -117,14 +311,12 @@ function reduceSpacepokerSettlementStateCore( return { ...current, gameState: { ...current.gameState, myTurn: false }, - terminalRecovery: null, }; } if (voluntary && current.terminalState !== 'none') { return { ...current, gameState: { ...current.gameState, myTurn: false }, - terminalRecovery: null, }; } if (voluntary && (current.gameState.handler === 3n || current.gameState.handler === 4n)) { @@ -145,7 +337,6 @@ function reduceSpacepokerSettlementStateCore( : player === 'you' ? 'conceded-by-you' : 'conceded-by-opponent', - terminalRecovery: null, }, { player, action }, ); @@ -159,7 +350,6 @@ function reduceSpacepokerSettlementStateCore( }, outcome: null, terminalState: 'settled', - terminalRecovery: null, }; } @@ -167,10 +357,7 @@ export function reduceSpacepokerSettlementState( current: SpacepokerHandState, outcome: SettlementOutcome, ): SpacepokerHandState { - return { - ...reduceSpacepokerSettlementStateCore(current, outcome), - pendingTerminalAction: null, - }; + return reduceSpacepokerSettlementStateCore(current, outcome); } function bigints(program: Program): bigint[] { @@ -339,7 +526,6 @@ export function reduceSpacepokerFeatureState( opponentBoost: items.length > 8 ? items[8].toBigInt() !== 0n : current.opponentBoost, outcome: outcomeFrom(items[1], items[2], items[3], items[4], items[5]), terminalState: 'revealed', - terminalRecovery: null, }, { player: 'opponent', action: 'reveal' }, ); @@ -349,120 +535,33 @@ export function reduceSpacepokerFeatureState( export function reduceSpacepokerDurableState( current: SpacepokerHandState | null, - event: DurableGameStateEvent, + event: GameInput, ): SpacepokerHandState | null { - if (event.type === 'abandoned' || event.type === 'remove-group') return null; - if (event.type === 'accepted-group') { - if (event.terms.gameType !== 'spacepoker') return current; - return current ?? initialState(event.isMyTurn, event.terms.unitSizeMojos); - } - if (event.type === 'feature-state') { - const state = spacepokerStateCodec.isState(event.state) ? event.state : null; - if (state === null) throw new Error('Invalid Space Poker feature-state payload'); - return state; + if (event.type === 'hand-started') { + if (event.init.handProposal.gameType !== 'spacepoker') return current; + const unitSizeMojos = + 'unitSizeMojos' in event.init.handProposal && + typeof event.init.handProposal.unitSizeMojos === 'bigint' + ? event.init.handProposal.unitSizeMojos + : 1n; + return current ?? initialState(event.init.canAct, unitSizeMojos); } if (!current) return null; - if (event.type === 'local-turn') { - return { - ...current, - gameState: { ...current.gameState, myTurn: event.isMyTurn }, - }; - } - if (event.type === 'settled') { + if (event.type === 'hand-ended') { return event.terminal.outcome ? reduceSpacepokerSettlementState(current, event.terminal.outcome) : current; } - if (event.type !== 'game-status') return current; - if (!event.readable) { + if (event.type === 'move-rejected') { return { ...current, - gameState: { ...current.gameState, myTurn: event.status === 'my-turn' }, + error: { tag: event.tag, message: event.message }, }; } + if (event.type !== 'opponent-moved' && event.type !== 'game-message') return current; const readableEvent = { - type: event.moverShare === null ? 'game-message' : 'opponent-moved', + type: event.type, readable: event.readable, } as const; - const next = reduceSpacepokerFeatureState(current, readableEvent); - return readableEvent.type === 'opponent-moved' ? { ...next, pendingTerminalAction: null } : next; + return reduceSpacepokerFeatureState(current, readableEvent); } - -export function validateSpacepokerTerms(terms: TermsFor<'spacepoker'>): boolean { - return ( - terms.myContribution === terms.theirContribution && - terms.myContribution > 0n && - terms.gameTimeout > 0n && - resolveSpacepokerUnitSize({ terms }) !== null && - terms.myContribution % terms.unitSizeMojos === 0n - ); -} - -export const spacepokerRegistration: GameFeatureRegistration<'spacepoker', SpacepokerHandState> = { - gameType: 'spacepoker', - displayName: 'Space Poker', - stateCodec: spacepokerStateCodec, - handMembershipDescription: 'exactly one currentHandGameId', - validateHandMembership: (gameIds) => gameIds.length === 1, - decodeFeatureState: (value) => (spacepokerStateCodec.isState(value) ? value : null), - lifecycle: { - proposalSenderGoesFirst: (iStarted) => !iStarted, - }, - compose: { - defaultDraft: () => ({ unitSize: 1n, stackSize: 10n }), - draftFromTerms: (terms) => ({ - unitSize: terms.unitSizeMojos, - stackSize: terms.myContribution / terms.unitSizeMojos, - }), - updateDraft: (current, update) => ({ ...current, ...update }), - toTerms(draft, gameTimeout) { - if (draft.stackSize > BigInt(Number.MAX_SAFE_INTEGER) || draft.stackSize <= 0n) return null; - const amount = draft.unitSize * draft.stackSize; - const terms = { - gameType: 'spacepoker' as const, - myContribution: amount, - theirContribution: amount, - gameTimeout, - unitSizeMojos: draft.unitSize, - }; - return validateSpacepokerTerms(terms) ? terms : null; - }, - }, - decodeProposalTerms(base, parameterState) { - const unitSizeMojos = resolveSpacepokerUnitSize({ encodedParameterState: parameterState }); - if (unitSizeMojos === null) return null; - const terms = { gameType: 'spacepoker' as const, ...base, unitSizeMojos }; - return validateSpacepokerTerms(terms) ? terms : null; - }, - encodeProposalParameters(terms, iStarted) { - const unitSizeMojos = resolveSpacepokerUnitSize({ terms }); - if (!unitSizeMojos || !this.validateTerms(terms)) { - throw new Error('Space Poker proposal requires a valid positive unit size'); - } - return Program.fromList([ - Program.fromBigInt(terms.myContribution), - Program.fromBigInt(unitSizeMojos), - Program.fromBigInt(this.lifecycle.proposalSenderGoesFirst(iStarted) ? 1n : 0n), - ]); - }, - validateTerms: validateSpacepokerTerms, - termsEqual: (a, b) => equalBaseTerms(a, b) && a.unitSizeMojos === b.unitSizeMojos, - persistence: { - encodeExtras: (terms) => ({ spacepoker_unit_size: terms.unitSizeMojos.toString() }), - decodeExtras(base, extras) { - const raw = extras.spacepoker_unit_size; - if (raw === undefined) return null; - try { - const unitSizeMojos = BigInt(raw); - const terms = { gameType: 'spacepoker' as const, ...base, unitSizeMojos }; - return validateSpacepokerTerms(terms) ? terms : null; - } catch { - return null; - } - }, - }, - durableState: { - reduce: reduceGameStateSnapshot, - reduceEvent: reduceSpacepokerDurableState, - }, -}; diff --git a/games/spacepoker/ui/spacePoker.test.ts b/games/spacepoker/ui/spacePoker.test.ts new file mode 100644 index 000000000..92b7c01b4 --- /dev/null +++ b/games/spacepoker/ui/spacePoker.test.ts @@ -0,0 +1,395 @@ +import React from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; + +import { + EMPTY_GAME_TERMINAL_MODEL, + terminalGameHandSource, + type GameIntent, + type GameHandSource, + type LiveGamePort, + type PersistedGameState, +} from '../../host'; +import SpacePoker from './SpacePoker'; +import { reduceSpacepokerDurableState, reduceSpacepokerSettlementState } from './handProposal'; +import { spacePokerRankLabel } from './handPresentation'; +import { + spacePokerFooterStatus, + spacePokerTerminalBanners, + spacePokerTerminalCommentary, + spacePokerTransitionCommentary, +} from './statusPresentation'; +import { spacepokerStateCodec, type SpacepokerHandState } from './serialize'; +import { + isTerminalSpacepokerHandler, + SpHandler, + useSpacepokerHand, + type UseSpacepokerHandResult, +} from './useSpacepokerHand'; + +function handState(overrides: Partial = {}): SpacepokerHandState { + return { + gameState: { handler: SpHandler.MidRound, myTurn: true, N: 3n }, + playerHoleCards: [1n, 2n], + playerBoost: false, + opponentHoleCards: null, + opponentBoost: null, + communityCards: [3n, 4n, 5n, null, null], + halfPot: 1n, + lastRaise: 0n, + iRaisedLast: false, + handHistory: [], + outcome: null, + terminalState: 'none', + coinTossIOpen: true, + unitSizeMojos: 10n, + displayMode: 'units', + error: null, + ...overrides, + }; +} + +function liveSource(port: LiveGamePort, state: PersistedGameState): GameHandSource { + return { interactionMode: 'live', handState: state, port }; +} + +describe('Space Poker terminal UX', () => { + it('uses a single-character ten rank and recognizes terminal handlers', () => { + expect(spacePokerRankLabel(10n)).toBe('T'); + expect(isTerminalSpacepokerHandler(SpHandler.Folded)).toBe(true); + expect(isTerminalSpacepokerHandler(SpHandler.Showdown)).toBe(true); + expect(isTerminalSpacepokerHandler(SpHandler.End)).toBe(false); + }); + + it('presents terminal outcomes without stale turn text', () => { + expect(spacePokerFooterStatus(SpHandler.Showdown, 'Your turn')).toBe(''); + expect(spacePokerTransitionCommentary(SpHandler.End, false)).toBe( + 'Waiting for opponent to finish…', + ); + expect(spacePokerTerminalCommentary('revealed', 1n, 'settled_cleanly')).toBe( + 'You won at showdown.', + ); + expect(spacePokerTerminalCommentary('settled', null, 'opponent_timed_out')).toBe( + 'Opponent timed out.', + ); + expect(spacePokerTerminalBanners('won-by-opponent-failure', null)).toEqual({ + player: 'win', + opponent: null, + }); + }); + + it('preserves accepted fold and reveal presentation through settlement reduction', () => { + const folded = handState({ + gameState: { handler: SpHandler.Folded, myTurn: false, N: 3n }, + handHistory: [{ player: 'you', action: 'fold' }], + terminalState: 'folded-by-you', + }); + expect(reduceSpacepokerSettlementState(folded, 'we_accepted')).toEqual(folded); + + const revealed = handState({ + gameState: { handler: SpHandler.Showdown, myTurn: false, N: 1n }, + outcome: { + result: 1n, + playerHandCards: [], + playerHandEval: [], + opponentHandCards: [], + opponentHandEval: [], + }, + handHistory: [{ player: 'you', action: 'reveal' }], + terminalState: 'revealed', + }); + expect(reduceSpacepokerSettlementState(revealed, 'settled_cleanly')).toEqual(revealed); + }); + + it('reduces current opponent input directly into durable state', () => { + const current = handState({ gameState: { handler: SpHandler.CommitA, myTurn: false, N: 4n } }); + const next = reduceSpacepokerDurableState(current, { + type: 'opponent-moved', + gameId: '7', + readable: new Uint8Array(), + moverShare: '0', + iStarted: false, + }); + expect(next?.gameState).toEqual({ handler: SpHandler.CommitB, myTurn: true, N: 4n }); + }); +}); + +describe('Space Poker machine-owned hand state', () => { + let renderer: ReactTestRenderer | null = null; + const originalWindow = globalThis.window; + + beforeAll(() => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { addEventListener: jest.fn(), removeEventListener: jest.fn() }, + }); + }); + afterEach(() => { + if (renderer) act(() => renderer?.unmount()); + renderer = null; + }); + afterAll(() => { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + }); + + it('derives each player stack and maximum opening raise from half the game amount', () => { + const persisted = spacepokerStateCodec.encode( + handState({ + gameState: { handler: SpHandler.BeginRound, myTurn: true, N: 4n }, + unitSizeMojos: 1n, + }), + ); + const dispatch = jest.fn(); + const port = { isChannelReady: () => true, dispatch } as unknown as LiveGamePort; + let hand: UseSpacepokerHandResult | undefined; + + function Harness() { + hand = useSpacepokerHand( + liveSource(port, persisted), + '7', + 20n, + 1n, + EMPTY_GAME_TERMINAL_MODEL, + ); + return null; + } + + act(() => { + renderer = create(React.createElement(Harness)); + }); + expect(hand?.playerStack).toBe(9n); + expect(hand?.opponentStack).toBe(9n); + + act(() => hand!.handleRaise(hand!.playerStack)); + const intent = dispatch.mock.calls[0][0] as Extract< + GameIntent, + { type: 'make-move' } + >; + expect(intent.readable?.toBigInt()).toBe(9n); + }); + + it('uses the per-player stack when formatting a terminal all-in log', () => { + const port = { isChannelReady: () => false, dispatch: jest.fn() } as LiveGamePort; + const onGameLog = jest.fn(); + const render = (state: SpacepokerHandState) => + React.createElement(SpacePoker, { + handSource: liveSource(port, spacepokerStateCodec.encode(state)), + gameId: '7', + betSize: '20', + unitSizeMojos: '1', + onGameLog, + terminal: EMPTY_GAME_TERMINAL_MODEL, + }); + const initial = handState({ unitSizeMojos: 1n }); + + act(() => { + renderer = create(render(initial)); + }); + act(() => { + renderer?.update( + render({ + ...initial, + gameState: { handler: SpHandler.Folded, myTurn: false, N: 1n }, + handHistory: [{ player: 'you', action: 'raise', units: 9n }], + terminalState: 'folded-by-opponent', + }), + ); + }); + + expect(onGameLog).toHaveBeenCalledTimes(1); + expect((onGameLog.mock.calls[0][0] as string[]).join(' ')).toContain('all'); + }); + + it('preserves delayed canonical gameplay state and displays the rejection', () => { + const current = handState(); + const next = reduceSpacepokerDurableState(current, { + type: 'move-rejected', + gameId: '7', + tag: 'ui_protocol_mismatch', + message: 'Space Poker move was rejected.', + }); + expect(next).toEqual({ + ...current, + error: { tag: 'ui_protocol_mismatch', message: 'Space Poker move was rejected.' }, + }); + + const port = { isChannelReady: () => true, dispatch: jest.fn() } as LiveGamePort; + act(() => { + renderer = create( + React.createElement(SpacePoker, { + handSource: liveSource(port, spacepokerStateCodec.encode(next!)), + gameId: '7', + betSize: '100', + unitSizeMojos: '10', + terminal: EMPTY_GAME_TERMINAL_MODEL, + }), + ); + }); + expect( + renderer!.root.findAll((node) => node.props.children === 'Space Poker move was rejected.'), + ).toHaveLength(1); + }); + + it('clears rejection feedback in the next valid local move candidate', () => { + const persisted = spacepokerStateCodec.encode( + handState({ error: { tag: 'ui_protocol_mismatch', message: 'Rejected.' } }), + ); + let candidate: SpacepokerHandState | null = null; + const port = { + isChannelReady: () => true, + dispatch: (intent: GameIntent) => { + candidate = intent.state; + }, + } as LiveGamePort; + let hand: UseSpacepokerHandResult | undefined; + function Harness() { + hand = useSpacepokerHand( + liveSource(port, persisted), + '7', + 100n, + 10n, + EMPTY_GAME_TERMINAL_MODEL, + ); + return null; + } + + act(() => { + renderer = create(React.createElement(Harness)); + }); + act(() => hand!.handleCheck()); + + expect(candidate).toMatchObject({ error: null }); + }); + + it('leaves render state unchanged when a local command is rejected', () => { + const persisted = spacepokerStateCodec.encode(handState()); + let rejected: GameIntent | null = null; + const port = { + isChannelReady: () => true, + dispatch: (intent: GameIntent) => { + rejected = intent; + throw new Error('check rejected'); + }, + } as unknown as LiveGamePort; + let hand: UseSpacepokerHandResult | undefined; + + function Harness() { + hand = useSpacepokerHand( + liveSource(port, persisted), + '7', + 100n, + 10n, + EMPTY_GAME_TERMINAL_MODEL, + ); + return null; + } + + act(() => { + renderer = create(React.createElement(Harness)); + }); + expect(() => act(() => hand?.handleCheck())).toThrow('check rejected'); + expect(rejected).toMatchObject({ + type: 'make-move', + gameId: '7', + state: { + gameState: { handler: SpHandler.MidRound, myTurn: false, N: 3n }, + handHistory: [{ player: 'you', action: 'check' }], + }, + }); + act(() => renderer?.update(React.createElement(Harness))); + expect(hand?.gameState).toEqual({ handler: SpHandler.MidRound, myTurn: true, N: 3n }); + expect(hand?.handHistory).toEqual([]); + }); + + it('commits an accepted codec-valid fold candidate through the live port', () => { + let persisted = spacepokerStateCodec.encode(handState()); + const committed: GameIntent[] = []; + const port = { + isChannelReady: () => true, + dispatch: (intent: GameIntent) => { + committed.push(intent); + persisted = spacepokerStateCodec.encode(intent.state); + }, + } as unknown as LiveGamePort; + let hand: UseSpacepokerHandResult | undefined; + + function Harness() { + hand = useSpacepokerHand( + liveSource(port, persisted), + '7', + 100n, + 10n, + EMPTY_GAME_TERMINAL_MODEL, + ); + return null; + } + act(() => { + renderer = create(React.createElement(Harness)); + }); + act(() => hand?.handleFold()); + + expect(committed).toHaveLength(1); + expect(committed[0]).toMatchObject({ type: 'accept-settlement', gameId: '7' }); + expect(spacepokerStateCodec.isState(committed[0].state)).toBe(true); + expect(committed[0].state).toMatchObject({ + gameState: { handler: SpHandler.Folded, myTurn: false, N: 3n }, + handHistory: [{ player: 'you', action: 'fold' }], + terminalState: 'folded-by-you', + }); + act(() => renderer?.update(React.createElement(Harness))); + expect(hand?.gameState).toEqual({ handler: SpHandler.Folded, myTurn: false, N: 3n }); + expect(hand?.handHistory).toEqual([{ player: 'you', action: 'fold' }]); + expect(hand?.terminalState).toBe('folded-by-you'); + }); + + it('decodes the current hand source again on every render', () => { + const port = { isChannelReady: () => true, dispatch: jest.fn() } as LiveGamePort; + let persisted = spacepokerStateCodec.encode(handState()); + let hand: UseSpacepokerHandResult | undefined; + + function Harness() { + hand = useSpacepokerHand( + liveSource(port, persisted), + '7', + 100n, + 10n, + EMPTY_GAME_TERMINAL_MODEL, + ); + return null; + } + act(() => { + renderer = create(React.createElement(Harness)); + }); + expect(hand?.lastRaise).toBe(0n); + + persisted = spacepokerStateCodec.encode( + handState({ + lastRaise: 4n, + handHistory: [{ player: 'opponent', action: 'raise', units: 4n }], + }), + ); + act(() => renderer?.update(React.createElement(Harness))); + expect(hand?.lastRaise).toBe(4n); + expect(hand?.handHistory).toEqual([{ player: 'opponent', action: 'raise', units: 4n }]); + }); + + it('does not expose protocol actions from a terminal hand source', () => { + const source = terminalGameHandSource(spacepokerStateCodec.encode(handState())); + act(() => { + renderer = create( + React.createElement(SpacePoker, { + handSource: source, + gameId: '7', + betSize: '100', + unitSizeMojos: '10', + onGameLog: jest.fn(), + terminal: EMPTY_GAME_TERMINAL_MODEL, + }), + ); + }); + const actionButtons = renderer!.root + .findAllByType('button') + .filter((button) => ['Check', 'Raise', 'Fold'].includes(String(button.children[0]))); + expect(actionButtons.length).toBeGreaterThan(0); + expect(actionButtons.every((button) => button.props.disabled)).toBe(true); + }); +}); diff --git a/front-end/src/features/spacePoker/statusPresentation.ts b/games/spacepoker/ui/statusPresentation.ts similarity index 98% rename from front-end/src/features/spacePoker/statusPresentation.ts rename to games/spacepoker/ui/statusPresentation.ts index 8c3651033..bc396f1bc 100644 --- a/front-end/src/features/spacePoker/statusPresentation.ts +++ b/games/spacepoker/ui/statusPresentation.ts @@ -1,4 +1,4 @@ -import { settlementLabel, type SettlementOutcome } from '../../lib/settlement'; +import { settlementLabel, type SettlementOutcome } from '../../host'; import { isTerminalSpacepokerHandler, SpHandler, type SpTerminalState } from './useSpacepokerHand'; export type HoleCardsBannerKind = 'fold' | 'concede' | 'win' | 'tie' | null; diff --git a/games/spacepoker/ui/unitSize.ts b/games/spacepoker/ui/unitSize.ts new file mode 100644 index 000000000..6b397abd2 --- /dev/null +++ b/games/spacepoker/ui/unitSize.ts @@ -0,0 +1,95 @@ +import { Program } from 'clvm-lib'; +import { + readClvmAtom, + readClvmFlag, + readClvmList, + readClvmProgram, + type FactoryParameterCodec, + type PersistedGameState, + type HandProposal, +} from '../../host'; +import { spacepokerStateCodec } from './serialize'; +function positive(value: bigint | undefined): bigint | null { + return value !== undefined && value > 0n ? value : null; +} + +export type SpacepokerTerms = HandProposal & { unitSizeMojos: bigint }; + +export function spacepokerTermsOf(handProposal: HandProposal): SpacepokerTerms | null { + if (handProposal.gameType !== 'spacepoker') return null; + const unitSizeMojos = (handProposal as SpacepokerTerms).unitSizeMojos; + return positive(unitSizeMojos) ? (handProposal as SpacepokerTerms) : null; +} + +export type SpacepokerFactoryParameters = { + perPlayerStake: bigint; + betUnit: bigint; + senderGoesFirst: boolean; +}; + +export const spacepokerFactoryParameters: FactoryParameterCodec = { + decode(value) { + const program = readClvmProgram(value); + if (!program) return null; + const items = readClvmList(program, 3); + if (!items) return null; + const perPlayerStake = readClvmAtom(items[0]); + const betUnit = readClvmAtom(items[1]); + const senderGoesFirst = readClvmFlag(items[2]); + if ( + perPlayerStake === null || + betUnit === null || + senderGoesFirst === null || + perPlayerStake <= 0n || + betUnit <= 0n || + perPlayerStake % betUnit !== 0n + ) { + return null; + } + return { perPlayerStake, betUnit, senderGoesFirst }; + }, + encode(params) { + return Program.fromList([ + Program.fromBigInt(params.perPlayerStake), + Program.fromBigInt(params.betUnit), + Program.fromBigInt(params.senderGoesFirst ? 1n : 0n), + ]); + }, +}; + +export function decodeSpacepokerUnitSize(value: unknown): bigint | null { + return spacepokerFactoryParameters.decode(value)?.betUnit ?? null; +} + +/** + * The sole resolver for Space Poker's protocol unit. Every source is validated, + * and multiple available sources must agree. + */ +export function resolveSpacepokerUnitSize(input: { + terms?: HandProposal | null; + persistedState?: PersistedGameState | null; + encodedParameterState?: unknown; +}): bigint | null { + const candidates: bigint[] = []; + if (input.terms && input.terms.gameType === 'spacepoker') { + const terms = spacepokerTermsOf(input.terms); + if (!terms) return null; + candidates.push(terms.unitSizeMojos); + } + if (input.persistedState) { + const state = spacepokerStateCodec.decode(input.persistedState); + if (input.persistedState.gameType === 'spacepoker' && !state) return null; + if (state) { + const value = positive(state.unitSizeMojos); + if (!value) return null; + candidates.push(value); + } + } + if (input.encodedParameterState !== undefined) { + const value = decodeSpacepokerUnitSize(input.encodedParameterState); + if (!value) return null; + candidates.push(value); + } + if (candidates.length === 0) return null; + return candidates.every((value) => value === candidates[0]) ? candidates[0] : null; +} diff --git a/games/spacepoker/ui/useSpacepokerHand.ts b/games/spacepoker/ui/useSpacepokerHand.ts new file mode 100644 index 000000000..e713afc2a --- /dev/null +++ b/games/spacepoker/ui/useSpacepokerHand.ts @@ -0,0 +1,368 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Program } from 'clvm-lib'; +import { + gameHandState, + requireLiveGameHandSource, + type GameHandSource, + type PersistedGameState, +} from '../../host'; +import { useGameHost } from '../../host/ui'; +import type { GameTerminalModel, SettlementOutcome } from '../../host'; +import { + spacepokerStateCodec, + type SpacepokerDisplayMode, + type SpacepokerHandState, + type SpGameState, + type SpHandEntry, + type SpHandler as SpHandlerType, + type SpOutcome, + type SpTerminalState, +} from './serialize'; + +export type { + SpacepokerDisplayMode, + SpacepokerHandState, + SpGameState, + SpHandEntry, + SpOutcome, + SpTerminalState, +} from './serialize'; + +type LocalGameCommand = + | { type: 'make-move'; readable: Program | null } + | { type: 'accept-settlement' } + | { type: 'cheat'; moverShare: bigint }; + +// These mirror the handler names in the Chialisp. The durable reducer advances +// this state for protocol inputs, while accepted local intents commit their +// candidate state through the live game port. +export const SpHandler = { + CommitA: 0n, + CommitB: 1n, + BeginRound: 2n, + MidRound: 3n, + End: 4n, + Showdown: 5n, + Folded: 6n, +} as const; +export type SpHandler = SpHandlerType; + +export function isTerminalSpacepokerHandler(handler: SpHandler): boolean { + return handler === SpHandler.Showdown || handler === SpHandler.Folded; +} + +export interface UseSpacepokerHandResult { + gameState: SpGameState; + playerHoleCards: [bigint, bigint] | null; + playerBoost: boolean; + opponentHoleCards: [bigint, bigint] | null; + opponentBoost: boolean | null; + communityCards: (bigint | null)[]; + pot: bigint; + playerStack: bigint; + opponentStack: bigint; + betUnit: bigint; + handHistory: SpHandEntry[]; + outcome: SpOutcome | null; + error: SpacepokerHandState['error']; + terminalOutcome: SettlementOutcome | null; + terminalState: SpTerminalState; + lastRaise: bigint; + coinTossIOpen: boolean | null; + unitSizeMojos: bigint; + displayMode: SpacepokerDisplayMode; + setDisplayMode: (mode: SpacepokerDisplayMode) => void; + formatBet: (units: bigint) => string; + + handleCheck: () => void; + handleRaise: (units: bigint) => void; + handleCall: () => void; + handleFold: () => void; + handleCheat: () => void; +} + +function formatXch(mojos: bigint, xchLabel: string): string { + const sign = mojos < 0n ? '-' : ''; + const abs = mojos < 0n ? -mojos : mojos; + const s = abs.toString().padStart(13, '0'); + const whole = s.slice(0, -12).replace(/^0+/, '') || '0'; + const frac = s.slice(-12).replace(/0+$/, ''); + return `${sign}${frac ? `${whole}.${frac}` : whole} ${xchLabel}`; +} + +export function useSpacepokerHand( + handSource: GameHandSource, + gameId: string, + betSize: bigint, + unitSizeMojos: bigint, + terminal: GameTerminalModel, +): UseSpacepokerHandResult { + const { currencyLabels } = useGameHost(); + const persistedState = gameHandState(handSource); + const state = spacepokerStateCodec.decode(persistedState); + if (!state) throw new Error('Space Poker requires initialized durable game state'); + if (unitSizeMojos <= 0n) throw new Error('Space Poker requires a positive unit size'); + if (state.unitSizeMojos !== unitSizeMojos) { + throw new Error('Space Poker persisted unit size does not match proposal terms'); + } + + const interactive = handSource.interactionMode === 'live'; + const betUnit = state.unitSizeMojos; + const stackSize = betSize / 2n / betUnit; + const pot = 2n * state.halfPot + state.lastRaise; + const playerStack = stackSize - state.halfPot - (state.iRaisedLast ? state.lastRaise : 0n); + const opponentStack = stackSize - state.halfPot - (state.iRaisedLast ? 0n : state.lastRaise); + const handSourceRef = useRef(handSource); + const gameIdRef = useRef(gameId); + handSourceRef.current = handSource; + gameIdRef.current = gameId; + + const [terminalDisplayMode, setTerminalDisplayMode] = useState( + null, + ); + const displayMode = interactive ? state.displayMode : (terminalDisplayMode ?? state.displayMode); + + const currentDurableState = useCallback((): SpacepokerHandState => { + const current = spacepokerStateCodec.decode(gameHandState(handSourceRef.current)); + if (!current) throw new Error('Space Poker requires initialized durable game state'); + return current; + }, []); + + const commitLocalAction = useCallback( + (update: (current: SpacepokerHandState) => SpacepokerHandState, command: LocalGameCommand) => { + const controller = requireLiveGameHandSource(handSourceRef.current); + const id = gameIdRef.current; + if (!id) return; + const next = { ...update(currentDurableState()), error: null }; + controller.dispatch( + command.type === 'make-move' + ? { type: 'make-move', gameId: id, readable: command.readable, state: next } + : command.type === 'accept-settlement' + ? { type: 'accept-settlement', gameId: id, state: next } + : { type: 'cheat', gameId: id, moverShare: command.moverShare, state: next }, + ); + }, + [currentDurableState], + ); + + const setDisplayMode = useCallback( + (mode: SpacepokerDisplayMode) => { + if (handSourceRef.current.interactionMode === 'terminal') { + setTerminalDisplayMode(mode); + return; + } + const controller = requireLiveGameHandSource(handSourceRef.current); + const current = currentDurableState(); + controller.dispatch({ + type: 'update-local-state', + state: { ...current, displayMode: mode }, + }); + }, + [currentDurableState], + ); + + const autoFiredSnapshotRef = useRef | null>(null); + useEffect(() => { + if (!interactive || !persistedState || terminal.type !== 'none') return; + if (state.terminalState !== 'none' || isTerminalSpacepokerHandler(state.gameState.handler)) + return; + const { handler, myTurn, N } = state.gameState; + if (!myTurn || !requireLiveGameHandSource(handSourceRef.current).isChannelReady()) return; + + const submitOnce = ( + update: (current: SpacepokerHandState) => SpacepokerHandState, + command: LocalGameCommand, + ) => { + if (autoFiredSnapshotRef.current === persistedState) return; + autoFiredSnapshotRef.current = persistedState; + commitLocalAction(update, command); + }; + + if (handler === SpHandler.CommitA || handler === SpHandler.CommitB) { + submitOnce( + (current) => ({ ...current, gameState: { ...current.gameState, myTurn: false } }), + { + type: 'make-move', + readable: null, + }, + ); + return; + } + if (handler === SpHandler.BeginRound && N === 4n && state.coinTossIOpen === false) { + submitOnce( + (current) => ({ ...current, gameState: { ...current.gameState, myTurn: false } }), + { + type: 'make-move', + readable: null, + }, + ); + return; + } + if ( + (handler === SpHandler.BeginRound || handler === SpHandler.MidRound) && + state.lastRaise === 0n && + playerStack <= 0n + ) { + if (handler === SpHandler.BeginRound) { + submitOnce( + (current) => ({ + ...current, + gameState: { handler: SpHandler.MidRound, myTurn: false, N }, + handHistory: [...current.handHistory, { player: 'you', action: 'check' }], + }), + { type: 'make-move', readable: Program.fromBigInt(0n) }, + ); + } else { + submitOnce( + (current) => ({ + ...current, + gameState: + N === 1n + ? { handler: SpHandler.End, myTurn: false, N: 1n } + : { handler: SpHandler.BeginRound, myTurn: false, N: N - 1n }, + halfPot: current.halfPot + current.lastRaise, + lastRaise: 0n, + handHistory: [ + ...current.handHistory, + { player: 'you', action: 'check', endsStreet: true }, + ], + }), + { type: 'make-move', readable: null }, + ); + } + return; + } + if (handler === SpHandler.End && state.outcome) { + if (state.outcome.result >= 0n) { + submitOnce( + (current) => ({ + ...current, + gameState: { handler: SpHandler.Showdown, myTurn: false, N }, + handHistory: [...current.handHistory, { player: 'you', action: 'reveal' }], + terminalState: 'revealed', + }), + { type: 'make-move', readable: null }, + ); + } else { + submitOnce( + (current) => ({ + ...current, + gameState: { handler: SpHandler.Showdown, myTurn: false, N }, + handHistory: [...current.handHistory, { player: 'you', action: 'concede' }], + terminalState: 'conceded-by-you', + }), + { type: 'accept-settlement' }, + ); + } + } + }, [commitLocalAction, interactive, persistedState, playerStack, state, terminal.type]); + + const handleCheck = useCallback(() => { + commitLocalAction( + (current) => ({ + ...current, + gameState: { handler: SpHandler.MidRound, myTurn: false, N: current.gameState.N }, + handHistory: [...current.handHistory, { player: 'you', action: 'check' }], + }), + { type: 'make-move', readable: Program.fromBigInt(0n) }, + ); + }, [commitLocalAction]); + + const handleRaise = useCallback( + (units: bigint) => { + commitLocalAction( + (current) => ({ + ...current, + gameState: { handler: SpHandler.MidRound, myTurn: false, N: current.gameState.N }, + halfPot: current.halfPot + current.lastRaise, + lastRaise: units, + iRaisedLast: true, + handHistory: [...current.handHistory, { player: 'you', action: 'raise', units }], + }), + { type: 'make-move', readable: Program.fromBigInt(units * betUnit) }, + ); + }, + [betUnit, commitLocalAction], + ); + + const handleCall = useCallback(() => { + commitLocalAction( + (current) => { + const action = current.lastRaise > 0n ? 'call' : 'check'; + return { + ...current, + gameState: + current.gameState.N === 1n + ? { handler: SpHandler.End, myTurn: false, N: 1n } + : { handler: SpHandler.BeginRound, myTurn: false, N: current.gameState.N - 1n }, + halfPot: current.halfPot + current.lastRaise, + lastRaise: 0n, + handHistory: [ + ...current.handHistory, + { player: 'you', action, ...(action === 'check' ? { endsStreet: true } : {}) }, + ], + outcome: current.gameState.N === 1n ? null : current.outcome, + }; + }, + { type: 'make-move', readable: null }, + ); + }, [commitLocalAction]); + + const handleFold = useCallback(() => { + commitLocalAction( + (current) => ({ + ...current, + gameState: { handler: SpHandler.Folded, myTurn: false, N: current.gameState.N }, + handHistory: [...current.handHistory, { player: 'you', action: 'fold' }], + terminalState: 'folded-by-you', + }), + { type: 'accept-settlement' }, + ); + }, [commitLocalAction]); + + const handleCheat = useCallback(() => { + commitLocalAction( + (current) => ({ ...current, gameState: { ...current.gameState, myTurn: false } }), + { type: 'cheat', moverShare: 0n }, + ); + }, [commitLocalAction]); + + const formatBet = useCallback( + (units: bigint): string => { + if (displayMode === 'units') return String(units); + const mojos = units * betUnit; + if (displayMode === 'mojos') return `${mojos.toLocaleString()} ${currencyLabels.mojos}`; + return formatXch(mojos, currencyLabels.xch); + }, + [betUnit, currencyLabels, displayMode], + ); + + return { + gameState: state.gameState, + playerHoleCards: state.playerHoleCards, + playerBoost: state.playerBoost, + opponentHoleCards: state.opponentHoleCards, + opponentBoost: state.opponentBoost, + communityCards: state.communityCards, + pot, + playerStack, + opponentStack, + betUnit, + handHistory: state.handHistory, + outcome: state.outcome, + error: state.error, + terminalOutcome: terminal.outcome, + terminalState: state.terminalState, + lastRaise: state.lastRaise, + coinTossIOpen: state.coinTossIOpen, + unitSizeMojos: betUnit, + displayMode, + setDisplayMode, + formatBet, + handleCheck, + handleRaise, + handleCall, + handleFold, + handleCheat, + }; +} diff --git a/hub/hub-frontend/src/hub.tsx b/hub/hub-frontend/src/hub.tsx index cddd0c3d5..53327c497 100644 --- a/hub/hub-frontend/src/hub.tsx +++ b/hub/hub-frontend/src/hub.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import { useHubSocket, ChallengeReceived, hubHsLog } from './useHubSocket'; +import { useHubSocket, ChallengeReceived } from './useHubSocket'; import { getSearchParams } from './util'; import { Edit, Cross, User, Crown, Swords } from 'lucide-react'; import { Button } from './button'; @@ -78,32 +78,17 @@ const HubScreen = () => { useEffect(() => { if (!aliasLoaded || autoJoinedRef.current) return; if (savedAlias) { - hubHsLog('alias_autojoin', { - session_id: sessionId, - unique_id: uniqueId, - alias_len: savedAlias.length, - }); autoJoinedRef.current = true; setMyAlias(savedAlias); setAliasConfirmed(true); notifyParentAlias(savedAlias); joinHub(savedAlias); - } else { - hubHsLog('alias_missing_waiting_for_user', { - session_id: sessionId, - unique_id: uniqueId, - }); } - }, [aliasLoaded, savedAlias, joinHub, sessionId, uniqueId]); + }, [aliasLoaded, savedAlias, joinHub]); function confirmAlias() { const trimmed = myAlias.trim(); if (!trimmed) return; - hubHsLog('alias_confirm', { - session_id: sessionId, - unique_id: uniqueId, - alias_len: trimmed.length, - }); setAlias(trimmed); setMyAlias(trimmed); setAliasConfirmed(true); diff --git a/hub/hub-frontend/src/useHubSocket.ts b/hub/hub-frontend/src/useHubSocket.ts index 1e9bf77f7..0d03f5247 100644 --- a/hub/hub-frontend/src/useHubSocket.ts +++ b/hub/hub-frontend/src/useHubSocket.ts @@ -37,23 +37,6 @@ type InboundMessage = | { type: 'keepalive' } | { type: 'error'; error?: string }; -let nextHubConnId = 1; - -export function hubHsLog(event: string, fields?: Record) { - const parts = [ - '[hub-hs]', - `ev=${event}`, - `iso=${new Date().toISOString()}`, - `mono_ms=${(typeof performance !== 'undefined' ? performance.now() : 0).toFixed(1)}`, - ]; - if (fields) { - for (const [k, v] of Object.entries(fields)) { - parts.push(`${k}=${String(v)}`); - } - } - console.warn(parts.join(' ')); -} - function toWsUrl(input: string): string { const url = new URL(input); url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -64,7 +47,6 @@ function toWsUrl(input: string): string { } export function useHubSocket(hubUrl: string, uniqueId: string, sessionId: string) { - const connIdRef = useRef(nextHubConnId++); const [players, setPlayers] = useState([]); const [hubUpdateReceived, setHubUpdateReceived] = useState(false); const [pendingChallenge, setPendingChallenge] = useState(null); @@ -76,7 +58,6 @@ export function useHubSocket(hubUrl: string, uniqueId: string, sessionId: string const [savedAlias, setSavedAlias] = useState(null); const [aliasLoaded, setAliasLoaded] = useState(false); const [publicId, setPublicId] = useState(null); - const uniqueIdRef = useRef(uniqueId); const wsRef = useRef(null); const pendingWsRef = useRef(null); const reconnectTimerRef = useRef(null); @@ -87,47 +68,22 @@ export function useHubSocket(hubUrl: string, uniqueId: string, sessionId: string const joinedAliasRef = useRef(null); const hasConnectedRef = useRef(false); - useEffect(() => { - uniqueIdRef.current = uniqueId; - }, [uniqueId]); - - const send = useCallback( - (payload: Record, queueIfClosed = true) => { - const ws = wsRef.current; - if (!ws || ws.readyState !== WebSocket.OPEN) { - if (queueIfClosed) { - pendingOutboundRef.current.push(payload); - hubHsLog('outbound_buffered', { - conn_id: connIdRef.current, - session_id: sessionId, - type: String(payload.type ?? 'unknown'), - buffered_len: pendingOutboundRef.current.length, - }); - } - return false; + const send = useCallback((payload: Record, queueIfClosed = true) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + if (queueIfClosed) { + pendingOutboundRef.current.push(payload); } - hubHsLog('outbound_sent', { - conn_id: connIdRef.current, - session_id: sessionId, - type: String(payload.type ?? 'unknown'), - }); - ws.send(JSON.stringify(payload)); - return true; - }, - [sessionId], - ); + return false; + } + ws.send(JSON.stringify(payload)); + return true; + }, []); useEffect(() => { if (!uniqueId) return; - const connId = connIdRef.current; const wsUrl = toWsUrl(hubUrl); - hubHsLog('connection_init', { - conn_id: connIdRef.current, - session_id: sessionId, - unique_id: uniqueId, - ws_url: wsUrl, - }); closingRef.current = false; setReconnectBlocked(false); @@ -142,22 +98,11 @@ export function useHubSocket(hubUrl: string, uniqueId: string, sessionId: string const connect = () => { if (closingRef.current) return; - hubHsLog('connect_start', { - conn_id: connIdRef.current, - session_id: sessionId, - ws_url: wsUrl, - }); const ws = new WebSocket(wsUrl); - const connectStartedAt = Date.now(); pendingWsRef.current = ws; const connectTimeout = window.setTimeout(() => { if (ws.readyState !== WebSocket.CONNECTING) return; - hubHsLog('ws_connect_timeout', { - conn_id: connIdRef.current, - session_id: sessionId, - elapsed_ms: Date.now() - connectStartedAt, - }); try { ws.close(); } catch { @@ -171,42 +116,21 @@ export function useHubSocket(hubUrl: string, uniqueId: string, sessionId: string pendingWsRef.current = null; wsRef.current = ws; reconnectAttemptRef.current = 0; - hubHsLog('ws_open', { - conn_id: connIdRef.current, - session_id: sessionId, - ready_state: ws.readyState, - connect_elapsed_ms: Date.now() - connectStartedAt, - }); setIsConnected(true); setHasConnected(true); hasConnectedRef.current = true; setInitialConnectionFailed(false); ws.send(JSON.stringify({ type: 'get_alias', session_id: sessionId })); - hubHsLog('get_alias_send', { - conn_id: connIdRef.current, - session_id: sessionId, - unique_id: uniqueIdRef.current, - }); if (joinedAliasRef.current) { const payload = { type: 'join', session_id: sessionId, alias: joinedAliasRef.current, }; - hubHsLog('join_resend_on_open', { - conn_id: connIdRef.current, - session_id: sessionId, - alias_len: joinedAliasRef.current.length, - }); ws.send(JSON.stringify(payload)); } if (pendingOutboundRef.current.length > 0) { const queued = pendingOutboundRef.current.splice(0, pendingOutboundRef.current.length); - hubHsLog('flush_buffered_outbound', { - conn_id: connIdRef.current, - session_id: sessionId, - count: queued.length, - }); for (const payload of queued) { ws.send(JSON.stringify(payload)); } @@ -238,11 +162,6 @@ export function useHubSocket(hubUrl: string, uniqueId: string, sessionId: string console.error('[hub] hub_update missing players array', msg); break; } - hubHsLog('hub_update_recv', { - conn_id: connIdRef.current, - session_id: sessionId, - players: msg.players.length, - }); setPlayers(msg.players); setHubUpdateReceived(true); break; @@ -256,11 +175,6 @@ export function useHubSocket(hubUrl: string, uniqueId: string, sessionId: string ); break; case 'alias_result': - hubHsLog('alias_result_recv', { - conn_id: connIdRef.current, - session_id: sessionId, - has_alias: msg.alias !== null, - }); setSavedAlias(msg.alias); setAliasLoaded(true); break; @@ -282,15 +196,6 @@ export function useHubSocket(hubUrl: string, uniqueId: string, sessionId: string } const isCurrentWs = wsRef.current === ws || pendingWsRef.current === ws; if (!isCurrentWs) return; - hubHsLog('ws_close', { - conn_id: connIdRef.current, - session_id: sessionId, - code: event.code, - reason: event.reason || '', - clean: event.wasClean, - closing: closingRef.current, - connect_elapsed_ms: Date.now() - connectStartedAt, - }); setIsConnected(false); wsRef.current = null; pendingWsRef.current = null; @@ -306,17 +211,7 @@ export function useHubSocket(hubUrl: string, uniqueId: string, sessionId: string RECONNECT_DELAYS[Math.min(reconnectAttemptRef.current, RECONNECT_DELAYS.length - 1)]; const delay = Math.round(base * (0.75 + Math.random() * 0.5)); reconnectAttemptRef.current++; - hubHsLog('reconnect_timer_set', { - conn_id: connIdRef.current, - session_id: sessionId, - delay_ms: delay, - attempt: reconnectAttemptRef.current, - }); reconnectTimerRef.current = window.setTimeout(() => { - hubHsLog('reconnect_timer_fire', { - conn_id: connIdRef.current, - session_id: sessionId, - }); connect(); }, delay); }; @@ -325,11 +220,6 @@ export function useHubSocket(hubUrl: string, uniqueId: string, sessionId: string clearTimeout(connectTimeout); const isCurrentWs = wsRef.current === ws || pendingWsRef.current === ws; if (!isCurrentWs) return; - hubHsLog('ws_error', { - conn_id: connIdRef.current, - session_id: sessionId, - connect_elapsed_ms: Date.now() - connectStartedAt, - }); try { ws.close(); } catch { @@ -357,10 +247,6 @@ export function useHubSocket(hubUrl: string, uniqueId: string, sessionId: string return () => { window.removeEventListener('beforeunload', onBeforeUnload); closingRef.current = true; - hubHsLog('connection_cleanup', { - conn_id: connId, - session_id: sessionId, - }); setIsConnected(false); if (reconnectTimerRef.current !== null) { clearTimeout(reconnectTimerRef.current); @@ -392,11 +278,6 @@ export function useHubSocket(hubUrl: string, uniqueId: string, sessionId: string const trimmed = alias.trim(); if (!trimmed) return; joinedAliasRef.current = trimmed; - hubHsLog('join_call', { - conn_id: connIdRef.current, - session_id: sessionId, - alias_len: trimmed.length, - }); send( { type: 'join', diff --git a/run-local-demo.sh b/run-local-demo.sh index 748a73dea..d0be67c74 100755 --- a/run-local-demo.sh +++ b/run-local-demo.sh @@ -8,6 +8,7 @@ WASM_DIR="$SCRIPT_DIR/wasm" HUB_SERVICE_DIR="$SCRIPT_DIR/hub/hub-service" HUB_FRONTEND_DIR="$SCRIPT_DIR/hub/hub-frontend" CLSP_DIR="$SCRIPT_DIR/clsp" +GAMES_DIR="$SCRIPT_DIR/games" GAME_PORT=${GAME_PORT:-3002} HUB_PORT=${HUB_PORT:-3003} @@ -154,6 +155,14 @@ echo "{\"hub\": \"http://localhost:$HUB_PORT\"}" > "$GAME_NONCE_DIR/urls" mkdir -p "$GAME_NONCE_DIR/clsp/$(dirname "$f")" cp "$f" "$GAME_NONCE_DIR/clsp/$f" done) +(cd "$GAMES_DIR" && find . \( -name '*.hex' -o -name '*.dat' \) | while read -r f; do + mkdir -p "$GAME_NONCE_DIR/games/$(dirname "$f")" + cp "$f" "$GAME_NONCE_DIR/games/$f" +done) +if ! find "$GAME_NONCE_DIR/games" -name '*.hex' | grep -q .; then + echo "Error: no game factory .hex files copied into $GAME_NONCE_DIR/games" >&2 + exit 1 +fi if [ -d "$FE_DIR/public/images" ]; then cp -r "$FE_DIR/public/images" "$GAME_NONCE_DIR/images" fi diff --git a/src/channel_state/game_start_info.rs b/src/channel_state/game_start_info.rs index b09156c87..ea1310864 100644 --- a/src/channel_state/game_start_info.rs +++ b/src/channel_state/game_start_info.rs @@ -1,17 +1,8 @@ -use std::rc::Rc; - -use crate::utils::proper_list; - -use clvmr::allocator::NodePtr; - use serde::{Deserialize, Serialize}; use crate::channel_state::game_handler::GameHandler; use crate::channel_state::types::StateUpdateProgram; -use crate::common::types::{ - atom_from_clvm, usize_from_atom, AllocEncoder, Amount, Error, GameID, Hash, Program, - ProgramRef, Timeout, -}; +use crate::common::types::{Amount, GameID, ProgramRef, Timeout}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct GameStartInfo { @@ -35,83 +26,4 @@ impl GameStartInfo { pub fn is_my_turn(&self) -> bool { matches!(self.game_handler, GameHandler::MyTurnHandler(_)) } - - pub fn from_clvm(allocator: &AllocEncoder, clvm: NodePtr) -> Result { - let lst = if let Some(lst) = proper_list(allocator.allocator_ref(), clvm, true) { - lst - } else { - return Err(Error::StrErr( - "game start info clvm wasn't a full list".to_string(), - )); - }; - - let required_length = 11; - - if lst.len() < required_length { - return Err(Error::StrErr(format!( - "game start info clvm needs at least {required_length} items" - ))); - } - - let returned_amount = Amount::from_clvm(allocator, lst[0])?; - let my_turn = - if let Some(a) = atom_from_clvm(allocator, lst[1]).and_then(|a| usize_from_atom(&a)) { - a != 0 - } else { - return Err(Error::StrErr("bad my_turn in game start info".to_string())); - }; - let returned_handler = if my_turn { - GameHandler::MyTurnHandler(Program::from_nodeptr(allocator, lst[2])?.into()) - } else { - GameHandler::TheirTurnHandler(Program::from_nodeptr(allocator, lst[2])?.into()) - }; - let returned_my_contribution = Amount::from_clvm(allocator, lst[3])?; - let returned_their_contribution = Amount::from_clvm(allocator, lst[4])?; - - let validation_prog = Rc::new(Program::from_nodeptr(allocator, lst[5])?); - let validation_program_hash = Hash::from_nodeptr(allocator, lst[6])?; - let validation_program = - StateUpdateProgram::new_hash(validation_prog, "initial", validation_program_hash); - let initial_state = Program::from_nodeptr(allocator, lst[7])?.into(); - let initial_move = if let Some(a) = atom_from_clvm(allocator, lst[8]) { - a.to_vec() - } else { - return Err(Error::StrErr("initial move wasn't an atom".to_string())); - }; - let initial_max_move_size = - if let Some(a) = atom_from_clvm(allocator, lst[9]).and_then(|a| usize_from_atom(&a)) { - a - } else { - return Err(Error::StrErr("bad initial max move size".to_string())); - }; - let initial_mover_share = Amount::from_clvm(allocator, lst[10])?; - - if lst.len() <= required_length + 1 { - return Err(Error::StrErr( - "game_start_info missing required game_id field".to_string(), - )); - } - let returned_game_id = GameID::from_clvm(allocator, lst[required_length])?; - - if lst.len() <= required_length + 2 { - return Err(Error::StrErr( - "game_start_info missing required timeout field".to_string(), - )); - } - let returned_timeout = Timeout::from_clvm(allocator, lst[required_length + 1])?; - - Ok(GameStartInfo { - game_id: returned_game_id, - amount: returned_amount, - game_handler: returned_handler, - timeout: returned_timeout, - my_contribution_this_game: returned_my_contribution, - their_contribution_this_game: returned_their_contribution, - initial_validation_program: validation_program, - initial_state, - initial_move, - initial_max_move_size, - initial_mover_share, - }) - } } diff --git a/src/channel_state/mod.rs b/src/channel_state/mod.rs index c79ea6b9f..98cab07d2 100644 --- a/src/channel_state/mod.rs +++ b/src/channel_state/mod.rs @@ -29,7 +29,6 @@ use crate::common::standard_coin::{ private_to_public_key, puzzle_for_pk, puzzle_for_synthetic_public_key, puzzle_hash_for_synthetic_public_key, standard_solution_partial, ChiaIdentity, }; -use crate::common::types::Sha256Input; use crate::common::types::{ Aggsig, AllocEncoder, Amount, BrokenOutCoinSpendInfo, CoinCondition, CoinID, CoinSpend, CoinString, Error, GameID, Hash, IntoErr, Node, PrivateKey, Program, PublicKey, Puzzle, @@ -2124,13 +2123,4 @@ impl ChannelState { ))) } } - - pub fn get_game_state_id(&self, env: &mut ChannelEnv<'_>) -> Result { - let mut bytes: Vec = Vec::with_capacity(self.live_games.len() * 32); - for l in self.live_games.iter() { - let ph = l.current_puzzle_hash(env.allocator)?; - bytes.extend_from_slice(ph.bytes()); - } - Ok(Sha256Input::Bytes(&bytes).hash()) - } } diff --git a/src/common/types/game_type.rs b/src/common/types/game_type.rs index ec5b87ebf..d7345189a 100644 --- a/src/common/types/game_type.rs +++ b/src/common/types/game_type.rs @@ -1,13 +1,50 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; -#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord, Hash)] -pub struct GameType(pub Vec); + +use crate::common::types::Hash; + +/// Protocol identity of a registered game: the first generated member's +/// `initial_validation_program_hash`. +/// +/// Registration discovers it by running the factory with representative valid +/// parameters; the factory program itself is not hashed as the identity. +/// +/// Package keys (`calpoker`, `krunk`, …) are bootstrap-only and never appear +/// in peer messages or persisted protocol state. +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct GameType(Hash); + +impl GameType { + pub fn from_hash(hash: Hash) -> Self { + GameType(hash) + } + + pub fn hash(&self) -> &Hash { + &self.0 + } + + pub fn bytes(&self) -> &[u8; 32] { + self.0.bytes() + } +} + +impl PartialOrd for GameType { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for GameType { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.0.bytes().cmp(other.0.bytes()) + } +} impl Serialize for GameType { fn serialize(&self, serializer: S) -> Result where S: Serializer, { - hex::encode(self.0.clone()).serialize(serializer) + hex::encode(self.0.bytes()).serialize(serializer) } } @@ -18,6 +55,13 @@ impl<'de> Deserialize<'de> for GameType { { let st = String::deserialize(deserializer)?; let slice = hex::decode(&st).map_err(serde::de::Error::custom)?; - Ok(GameType(slice.to_vec())) + let hash = Hash::from_slice(&slice).map_err(serde::de::Error::custom)?; + Ok(GameType::from_hash(hash)) + } +} + +impl std::fmt::Display for GameType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", hex::encode(self.0.bytes())) } } diff --git a/src/game_session.rs b/src/game_session.rs index bd7d59c7d..a8b889023 100644 --- a/src/game_session.rs +++ b/src/game_session.rs @@ -32,21 +32,24 @@ use crate::session_phases::types::{ SpendWalletReceiver, ToLocalUI, WalletSpendInterface, }; -#[cfg(test)] -use crate::session_phases::spend_channel_coin_phase::SpendChannelCoinPhase; #[cfg(test)] use crate::session_phases::OffChainPhase; +pub(crate) fn phase_operation_error(phase: &str, operation: &str) -> Error { + Error::StrErr(format!("{operation} is not available in {phase}")) +} + +/// Complete protocol surface implemented explicitly by every lifecycle phase. +/// +/// Methods intentionally have no behavioral defaults: a phase must state +/// whether each operation is active, invalid, or a deliberate no-op. #[typetag::serde] pub trait PeerLifecyclePhase { + fn phase_name(&self) -> &'static str; fn has_queued_message(&self) -> bool; fn process_queued_message(&mut self, env: &mut ChannelEnv<'_>) -> Result, Error>; - fn has_queued_action(&self) -> bool { - false - } - fn process_queued_action(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { - Ok(vec![]) - } + fn has_queued_action(&self) -> bool; + fn process_queued_action(&mut self, env: &mut ChannelEnv<'_>) -> Result, Error>; fn received_message( &mut self, env: &mut ChannelEnv<'_>, @@ -90,124 +93,92 @@ pub trait PeerLifecyclePhase { #[cfg(test)] fn self_accept_proposal( &mut self, - _env: &mut ChannelEnv<'_>, - _game_id: &GameID, - ) -> Result, Error> { - Err(Error::StrErr( - "self_accept_proposal: not in off-chain phase".to_string(), - )) - } + env: &mut ChannelEnv<'_>, + game_id: &GameID, + ) -> Result, Error>; fn take_next_phase(&mut self) -> Option>; - - fn new_block(&mut self, _height: u64) -> Result, Error> { - Ok(vec![]) - } - - fn handshake_finished(&self) -> bool { - true - } + fn new_block(&mut self, height: u64) -> Result, Error>; + fn handshake_finished(&self) -> bool; + fn is_on_chain(&self) -> bool; + fn start_handshake(&mut self, env: &mut ChannelEnv<'_>) -> Result, Error>; fn channel_offer( &mut self, - _env: &mut ChannelEnv<'_>, - _bundle: SpendBundle, - ) -> Result, Error> { - Ok(None) - } + env: &mut ChannelEnv<'_>, + bundle: SpendBundle, + ) -> Result, Error>; fn channel_transaction_completion( &mut self, - _env: &mut ChannelEnv<'_>, - _bundle: &SpendBundle, - ) -> Result, Error> { - Ok(None) - } + env: &mut ChannelEnv<'_>, + bundle: &SpendBundle, + ) -> Result, Error>; fn provide_launcher_coin( &mut self, - _env: &mut ChannelEnv<'_>, - _launcher_coin: CoinString, - ) -> Result, Error> { - Err(Error::StrErr( - "provide_launcher_coin not available in this phase".to_string(), - )) - } + env: &mut ChannelEnv<'_>, + launcher_coin: CoinString, + ) -> Result, Error>; fn provide_coin_spend_bundle( &mut self, - _env: &mut ChannelEnv<'_>, - _bundle: SpendBundle, - ) -> Result, Error> { - Err(Error::StrErr( - "provide_coin_spend_bundle not available in this phase".to_string(), - )) - } + env: &mut ChannelEnv<'_>, + bundle: SpendBundle, + ) -> Result, Error>; fn propose_games( &mut self, - _env: &mut ChannelEnv<'_>, - _games: &[GameProposal], - ) -> Result<(Vec, Vec), Error> { - Err(Error::StrErr( - "propose_games: not in off-chain phase".to_string(), - )) - } + env: &mut ChannelEnv<'_>, + games: &[GameProposal], + ) -> Result<(Vec, Vec), Error>; fn accept_proposal( &mut self, - _env: &mut ChannelEnv<'_>, - _game_id: &GameID, - ) -> Result, Error> { - Err(Error::StrErr( - "accept_proposal: not in off-chain phase".to_string(), - )) - } + env: &mut ChannelEnv<'_>, + game_id: &GameID, + ) -> Result, Error>; fn cancel_proposal( &mut self, - _env: &mut ChannelEnv<'_>, - _game_id: &GameID, - ) -> Result, Error> { - Err(Error::StrErr( - "cancel_proposal: not in off-chain phase".to_string(), - )) - } - fn shut_down(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { - Err(Error::StrErr( - "shut_down: not in off-chain phase".to_string(), - )) - } + env: &mut ChannelEnv<'_>, + game_id: &GameID, + ) -> Result, Error>; + fn shut_down(&mut self, env: &mut ChannelEnv<'_>) -> Result, Error>; fn go_on_chain( &mut self, - _env: &mut ChannelEnv<'_>, - _got_error: bool, - ) -> Result, Error> { - Ok(vec![]) - } - fn flush_pending_actions(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { - Ok(vec![]) - } - fn take_failed_queued_action(&mut self) -> Option<(GameID, FailedGameAction)> { - None - } - fn channel_state(&self) -> Result<&ChannelState, Error> { - Err(Error::StrErr( - "no channel handler in this phase".to_string(), - )) - } - - fn channel_status_snapshot(&self) -> Option { - None - } - - fn wallet_callback_failed(&mut self, _reason: String) {} - - fn has_active_on_chain_games(&self) -> bool { - false - } + env: &mut ChannelEnv<'_>, + got_error: bool, + ) -> Result, Error>; + fn flush_pending_actions(&mut self, env: &mut ChannelEnv<'_>) -> Result, Error>; + fn take_failed_queued_action(&mut self) -> Option<(GameID, FailedGameAction)>; + fn channel_state(&self) -> Result<&ChannelState, Error>; + fn channel_status_snapshot(&self) -> Option; + fn wallet_callback_failed(&mut self, reason: String); + fn has_active_on_chain_games(&self) -> bool; + fn timeout_claim_submitted( + &mut self, + semantic: TimeoutClaimSemantic, + ) -> Result, Error>; + fn timeout_claim_rearmed( + &mut self, + semantic: TimeoutClaimSemantic, + ) -> Result, Error>; /// Coin ids worth surfacing in the dashboard (channel/unroll/change/game/ - /// game-change), each tagged with its kind. Defaults to none, which is the - /// correct answer during handshake before any coin exists. - fn coins_of_interest(&self) -> Vec<(CoinOfInterest, CoinString)> { - vec![] - } + /// game-change), each tagged with its kind. + fn coins_of_interest(&self) -> Vec<(CoinOfInterest, CoinString)>; - fn as_any(&self) -> &dyn std::any::Any; - fn as_any_mut(&mut self) -> &mut dyn std::any::Any; + #[cfg(test)] + fn corrupt_state_for_testing(&mut self, new_sn: usize) -> Result<(), Error>; + #[cfg(test)] + fn force_unroll_spend_for_testing( + &self, + env: &mut ChannelEnv<'_>, + ) -> Result; + #[cfg(test)] + fn last_channel_coin_spend_info_for_testing(&self) -> Option; + #[cfg(test)] + fn force_stale_unroll_spend_for_testing( + &self, + env: &mut ChannelEnv<'_>, + saved: &ChannelCoinSpendInfo, + ) -> Result; + #[cfg(test)] + fn take_off_chain_phase_for_testing(&mut self) -> Option; + fn get_game_coin(&self, game_id: &GameID) -> Option; } impl SpendWalletReceiver for Box { @@ -408,7 +379,6 @@ impl WalletSpendInterface for GameSessionState { pub struct GameSession { state: GameSessionState, peer: Box, - amount: Amount, last_channel_status: Option, #[cfg(test)] #[serde(skip)] @@ -530,7 +500,6 @@ impl GameSession { Box::new(HandshakeReceiverPhase::new(phi)) as Box } }, - amount: config.my_contribution + config.their_contribution, last_channel_status: None, #[cfg(test)] saved_unroll_snapshot: None, @@ -573,27 +542,13 @@ impl GameSession { pub fn proposal_contributions_for_testing( &self, ) -> Result, Error> { - let handler = self - .peer - .as_any() - .downcast_ref::() - .ok_or_else(|| { - Error::StrErr("proposal_contributions_for_testing: not a OffChainPhase".to_string()) - })?; - let channel = handler.channel_state()?; + let channel = self.peer.channel_state()?; Ok(channel.proposal_contributions_for_testing()) } #[cfg(test)] pub fn allocated_balances_for_testing(&self) -> Result<(Amount, Amount), Error> { - let handler = self - .peer - .as_any() - .downcast_ref::() - .ok_or_else(|| { - Error::StrErr("allocated_balances_for_testing: not a OffChainPhase".to_string()) - })?; - let channel = handler.channel_state()?; + let channel = self.peer.channel_state()?; Ok(( channel.my_allocated_balance(), channel.their_allocated_balance(), @@ -602,36 +557,19 @@ impl GameSession { #[cfg(test)] pub fn corrupt_state_for_testing(&mut self, new_sn: usize) -> Result<(), Error> { - let ph = self - .peer - .as_any_mut() - .downcast_mut::() - .ok_or_else(|| { - Error::StrErr("corrupt_state_for_testing: not a OffChainPhase".to_string()) - })?; - ph.corrupt_state_for_testing(new_sn) + self.peer.corrupt_state_for_testing(new_sn) } #[cfg(test)] pub fn force_unroll_spend(&self, allocator: &mut AllocEncoder) -> Result { let mut env = ChannelEnv::new_with_genesis(allocator, &self.state.agg_sig_me_additional_data)?; - if let Some(ph) = self.peer.as_any().downcast_ref::() { - return ph.force_unroll_spend(&mut env); - } - if let Some(h) = self.peer.as_any().downcast_ref::() { - return h.force_unroll_spend(&mut env); - } - Err(Error::StrErr( - "force_unroll_spend: not available in this phase".to_string(), - )) + self.peer.force_unroll_spend_for_testing(&mut env) } #[cfg(test)] pub fn save_unroll_snapshot(&mut self) { - if let Some(ph) = self.peer.as_any().downcast_ref::() { - self.saved_unroll_snapshot = ph.get_last_channel_coin_spend_info().cloned(); - } + self.saved_unroll_snapshot = self.peer.last_channel_coin_spend_info_for_testing(); } #[cfg(test)] @@ -644,18 +582,8 @@ impl GameSession { })?; let mut env = ChannelEnv::new_with_genesis(allocator, &self.state.agg_sig_me_additional_data)?; - let ph = self - .peer - .as_any() - .downcast_ref::() - .ok_or_else(|| { - Error::StrErr("force_stale_unroll_spend: not a OffChainPhase".to_string()) - })?; - ph.force_stale_unroll_spend(&mut env, saved) - } - - pub fn amount(&self) -> Amount { - self.amount.clone() + self.peer + .force_stale_unroll_spend_for_testing(&mut env, saved) } /// Render the current protocol-level peer state as indented text for the @@ -688,20 +616,6 @@ impl GameSession { .collect() } - pub fn get_our_current_share(&self) -> Option { - self.peer - .channel_state() - .ok() - .map(|ch| ch.get_our_current_share()) - } - - pub fn get_their_current_share(&self) -> Option { - self.peer - .channel_state() - .ok() - .map(|ch| ch.get_their_current_share()) - } - pub fn is_peer_disconnected(&self) -> bool { self.state.peer_disconnected } @@ -891,10 +805,7 @@ impl GameSession { if let Some(next) = self.peer.take_next_phase() { self.peer = next; } - // Update phase metadata from current handler - use crate::session_phases::on_chain::OnChainPhase; - - self.state.is_on_chain = self.peer.as_any().downcast_ref::().is_some(); + self.state.is_on_chain = self.peer.is_on_chain(); self.state.is_failed = self .peer .channel_status_snapshot() @@ -989,33 +900,12 @@ impl GameSession { &mut self, semantic: TimeoutClaimSemantic, ) -> Result<(), Error> { - use crate::session_phases::spend_channel_coin_phase::SpendChannelCoinPhase; - - match semantic { - TimeoutClaimSemantic::ChannelTimeoutFinish => { - let changed = self - .peer - .as_any_mut() - .downcast_mut::() - .is_some_and(|phase| phase.timeout_claim_submitted(semantic)); - if changed { - self.emit_channel_status_if_changed(); - } - } - TimeoutClaimSemantic::GameOpponentTurn { id } - | TimeoutClaimSemantic::GameFinishTimeout { id } => { - let notification = self - .peer - .as_any_mut() - .downcast_mut::() - .and_then(|phase| phase.timeout_claim_status(id, true)); - if let Some(notification) = notification { - self.state - .events - .push_back(GameSessionEvent::Notification(notification)); - } - } + if let Some(notification) = self.peer.timeout_claim_submitted(semantic)? { + self.state + .events + .push_back(GameSessionEvent::Notification(notification)); } + self.emit_channel_status_if_changed(); Ok(()) } @@ -1023,33 +913,12 @@ impl GameSession { &mut self, semantic: TimeoutClaimSemantic, ) -> Result<(), Error> { - use crate::session_phases::spend_channel_coin_phase::SpendChannelCoinPhase; - - match semantic { - TimeoutClaimSemantic::ChannelTimeoutFinish => { - let changed = self - .peer - .as_any_mut() - .downcast_mut::() - .is_some_and(|phase| phase.timeout_claim_rearmed(semantic)); - if changed { - self.emit_channel_status_if_changed(); - } - } - TimeoutClaimSemantic::GameOpponentTurn { id } - | TimeoutClaimSemantic::GameFinishTimeout { id } => { - let notification = self - .peer - .as_any_mut() - .downcast_mut::() - .and_then(|phase| phase.timeout_claim_status(id, false)); - if let Some(notification) = notification { - self.state - .events - .push_back(GameSessionEvent::Notification(notification)); - } - } + if let Some(notification) = self.peer.timeout_claim_rearmed(semantic)? { + self.state + .events + .push_back(GameSessionEvent::Notification(notification)); } + self.emit_channel_status_if_changed(); Ok(()) } @@ -1444,18 +1313,6 @@ impl GameSession { self.peer.channel_state()?.get_reward_puzzle_hash(&mut env) } - pub fn get_game_state_id( - &mut self, - allocator: &mut AllocEncoder, - ) -> Result, Error> { - let mut env = - ChannelEnv::new_with_genesis(allocator, &self.state.agg_sig_me_additional_data)?; - match self.peer.channel_state() { - Ok(ch) => ch.get_game_state_id(&mut env).map(Some), - Err(_) => Ok(None), - } - } - pub fn set_funding_coin( &mut self, allocator: &mut AllocEncoder, @@ -1463,22 +1320,10 @@ impl GameSession { ) -> Result<(), Error> { self.state.funding_coin = Some(coin.clone()); - if !self.state.is_initiator { - return Ok(()); - } - let start_effect = { let mut env = ChannelEnv::new_with_genesis(allocator, &self.state.agg_sig_me_additional_data)?; - if let Some(hh) = self - .peer - .as_any_mut() - .downcast_mut::() - { - hh.start(&mut env)? - } else { - None - } + self.peer.start_handshake(&mut env)? }; let mut effects = Vec::new(); effects.extend(start_effect); @@ -1488,22 +1333,10 @@ impl GameSession { } pub fn start_handshake(&mut self, allocator: &mut AllocEncoder) -> Result<(), Error> { - if !self.state.is_initiator { - return Ok(()); - } - let start_effect = { let mut env = ChannelEnv::new_with_genesis(allocator, &self.state.agg_sig_me_additional_data)?; - if let Some(hh) = self - .peer - .as_any_mut() - .downcast_mut::() - { - hh.start(&mut env)? - } else { - None - } + self.peer.start_handshake(&mut env)? }; let mut effects = Vec::new(); effects.extend(start_effect); @@ -1759,14 +1592,9 @@ impl GameSession { #[cfg(test)] impl GameSession { - /// Get the on-chain game coin for a game (test harness only). Downcasts to - /// OnChainPhase when the cradle is in on-chain phase. + /// Get the on-chain game coin for a game (test harness only). pub fn get_game_coin(&self, game_id: &GameID) -> Option { - use crate::session_phases::on_chain::OnChainPhase; - if let Some(och) = self.peer.as_any().downcast_ref::() { - return och.get_game_coin(game_id); - } - None + self.peer.get_game_coin(game_id) } } @@ -1997,4 +1825,38 @@ mod genesis_challenge_tests { Hash::from_bytes(AGG_SIG_ME_ADDITIONAL_DATA) ); } + + #[test] + fn receiver_phase_explicitly_handles_start_and_rejects_game_proposals() { + let mut allocator = AllocEncoder::new(); + let mut rng = ChaCha8Rng::from_seed([2u8; 32]); + let identity = + ChiaIdentity::new(&mut allocator, rng.random::()).expect("identity"); + let mut session = GameSession::new_with_keys( + GameSessionConfig { + game_types: BTreeMap::new(), + have_potato: false, + identity, + my_contribution: Amount::new(100), + their_contribution: Amount::new(100), + channel_timeout: Timeout::new(5), + unroll_timeout: Timeout::new(15), + reward_puzzle_hash: PuzzleHash::from_bytes([2; 32]), + agg_sig_me_additional_data: Hash::from_bytes([0x11; 32]), + }, + rng.random(), + ); + + session + .start_handshake(&mut allocator) + .expect("receiver start is an intentional no-op"); + let error = session + .propose_games(&mut allocator, &[]) + .expect_err("receiver cannot propose games during handshake"); + assert!(matches!( + error, + Error::StrErr(message) + if message == "propose_games is not available in handshake receiver phase" + )); + } } diff --git a/src/games/mod.rs b/src/games/mod.rs index 2c965ff68..23054eace 100644 --- a/src/games/mod.rs +++ b/src/games/mod.rs @@ -1,20 +1,4 @@ -pub mod krunk_dict_tree; +include!(concat!(env!("OUT_DIR"), "/game_packages.rs")); -use chia_protocol::Bytes; - -use crate::common::types::GameType; - -/// Loads the krunk dictionary from `krunkwords.txt`, embedded at compile time. -/// Words are 5 ASCII letters; one per line. -pub fn krunk_dictionary() -> Vec { - include_str!("../../clsp/games/krunk/krunkwords.txt") - .lines() - .filter(|l| l.len() == 5) - .map(|w| Bytes::from(w.as_bytes().to_vec())) - .collect() -} - -/// The `GameType` key for krunk in the game type map. -pub fn krunk_game_type() -> GameType { - GameType(b"krunk".to_vec()) -} +pub use krunk::dict_tree as krunk_dict_tree; +pub use krunk::dictionary as krunk_dictionary; diff --git a/src/manifest_guards.rs b/src/manifest_guards.rs index 39adccabd..fa7dcf8ec 100644 --- a/src/manifest_guards.rs +++ b/src/manifest_guards.rs @@ -29,17 +29,18 @@ fn rs_files(dir: &Path, out: &mut Vec) { } } -/// Extract `clsp/.../*.hex` paths that appear inside double-quoted string -/// literals. Dynamic paths containing a `{}` format placeholder are returned -/// as-is; the caller skips them since they can't be checked statically. +/// Extract `clsp/.../*.hex` and `games/.../*.hex` paths that appear inside +/// double-quoted string literals. Dynamic paths containing a `{}` format +/// placeholder are returned as-is; the caller skips them since they can't be +/// checked statically. fn hex_literals(text: &str) -> Vec { let mut out = Vec::new(); let mut rest = text; - while let Some(pos) = rest.find("\"clsp/") { + while let Some(pos) = rest.find('"') { let after_quote = &rest[pos + 1..]; if let Some(end) = after_quote.find('"') { let lit = &after_quote[..end]; - if lit.ends_with(".hex") { + if lit.ends_with(".hex") && (lit.starts_with("clsp/") || lit.starts_with("games/")) { out.push(lit.to_string()); } rest = &after_quote[end + 1..]; @@ -106,6 +107,8 @@ fn every_test_module_is_registered_and_run() { fn every_referenced_hex_is_built() { let mut files = Vec::new(); rs_files(Path::new("src"), &mut files); + rs_files(Path::new("games"), &mut files); + rs_files(Path::new("wasm"), &mut files); let mut missing = Vec::new(); for file in &files { @@ -131,3 +134,142 @@ fn every_referenced_hex_is_built() { missing.join("\n ") ); } + +fn registry_keys() -> (Vec, Vec) { + let json: serde_json::Value = serde_json::from_str(&read("games/registry.json")) + .unwrap_or_else(|e| panic!("manifest guard: invalid games/registry.json: {e}")); + let strings = |field: &str| -> Vec { + json.get(field) + .and_then(|v| v.as_array()) + .unwrap_or_else(|| panic!("manifest guard: games/registry.json missing {field}")) + .iter() + .map(|item| { + item.as_str() + .unwrap_or_else(|| panic!("manifest guard: {field} entries must be strings")) + .to_string() + }) + .collect() + }; + (strings("production"), strings("test")) +} + +/// Every directory under `games/` except the JSON catalog and `games/host` +/// (the portable host contract, not a factory game) must be a registered +/// package, and every registered key must exist with conventional files. +/// Production packages must export the UI modules stitched by the generator. +#[test] +fn every_game_package_is_registered() { + let (production, test) = registry_keys(); + let mut registered = std::collections::BTreeSet::new(); + for key in production.iter().chain(test.iter()) { + assert!( + registered.insert(key.clone()), + "duplicate game package key {key} in games/registry.json" + ); + } + + let mut on_disk = std::collections::BTreeSet::new(); + for entry in fs::read_dir("games").expect("read games") { + let path = entry.expect("dir entry").path(); + if !path.is_dir() { + continue; + } + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap() + .to_string(); + if name.starts_with('.') || name == "host" { + continue; + } + on_disk.insert(name); + } + + let unregistered: Vec<_> = on_disk.difference(®istered).cloned().collect(); + let missing: Vec<_> = registered.difference(&on_disk).cloned().collect(); + assert!( + unregistered.is_empty(), + "games/* directories not listed in games/registry.json: {unregistered:?}" + ); + assert!( + missing.is_empty(), + "games/registry.json keys with no package directory: {missing:?}" + ); + + let mut missing_files = Vec::new(); + for key in ®istered { + let root = PathBuf::from("games").join(key); + for rel in ["rust/mod.rs", "rust/tests/mod.rs", "clsp/factory.clsp"] { + if !root.join(rel).is_file() { + missing_files.push(format!("games/{key}/{rel}")); + } + } + if production.iter().any(|k| k == key) { + for rel in [ + "ui/handProposal.ts", + "ui/handProposalForm.tsx", + "ui/play.tsx", + ] { + if !root.join(rel).is_file() { + missing_files.push(format!("games/{key}/{rel}")); + } + } + } + } + assert!( + missing_files.is_empty(), + "registered game packages missing conventional files: {missing_files:?}" + ); +} + +/// Game-owned `test_funs` collectors must exist and be pulled in through the +/// generated full-suite aggregator rather than a handwritten list. +#[test] +fn every_game_package_test_module_is_aggregated() { + let (production, test) = registry_keys(); + let mut missing = Vec::new(); + for key in production.iter().chain(test.iter()) { + let tests = PathBuf::from(format!("games/{key}/rust/tests/mod.rs")); + let src = read(tests.to_str().unwrap()); + if !src.contains("pub fn test_funs") { + missing.push(key.clone()); + } + } + assert!( + missing.is_empty(), + "game packages missing rust/tests/mod.rs `pub fn test_funs`: {missing:?}" + ); + + let simulator_rs = read("src/simulator/mod.rs"); + assert!( + simulator_rs.contains("game_package_test_funs()"), + "src/simulator/mod.rs must call generated game_package_test_funs()" + ); +} + +/// Production factory hex (and extra `.dat` presets) must exist after the +/// chialisp build so the frontend generator's preset list is not hollow. +#[test] +fn every_production_package_preset_exists() { + let (production, _) = registry_keys(); + let mut missing = Vec::new(); + for key in production { + let factory = PathBuf::from(format!("games/{key}/clsp/factory_{key}_factory.hex")); + if !factory.is_file() { + missing.push(factory.display().to_string()); + } + let clsp = PathBuf::from(format!("games/{key}/clsp")); + if let Ok(entries) = fs::read_dir(&clsp) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("dat") && !path.is_file() { + missing.push(path.display().to_string()); + } + } + } + } + assert!( + missing.is_empty(), + "production game presets missing after chialisp build: {missing:?}" + ); +} diff --git a/src/session_phases/effects.rs b/src/session_phases/effects.rs index 3d73f56a2..056e797da 100644 --- a/src/session_phases/effects.rs +++ b/src/session_phases/effects.rs @@ -3,8 +3,8 @@ use std::collections::VecDeque; use crate::channel_state::types::ReadableMove; use crate::channel_state::types::StateUpdateSignatures; use crate::common::types::{ - Aggsig, Amount, CoinID, CoinSpend, CoinString, GameID, GameType, Hash, ProgramRef, PuzzleHash, - SpendBundle, Timeout, + Aggsig, Amount, CoinID, CoinSpend, CoinString, GameID, GameType, Hash, Program, ProgramRef, + PuzzleHash, SpendBundle, Timeout, }; use crate::session_phases::handshake::{ CoinSpendRequest, HandshakePayloadB, HandshakePayloadC, HandshakePayloadD, HandshakePayloadE, @@ -178,6 +178,15 @@ pub enum SettlementOutcome { pub enum FailedGameAction { MakeMove, AcceptSettlement, + Cheat, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LocalActionKind { + MakeMove, + AcceptSettlement, + Cheat, } #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] @@ -248,6 +257,7 @@ pub enum GameNotification { initial_validation_program_hash: Hash, initial_state: ProgramRef, game_type: GameType, + parameters: Program, }, ProposalAccepted { id: GameID, @@ -276,6 +286,10 @@ pub enum GameNotification { tag: String, message: String, }, + LocalActionApplied { + id: GameID, + action: LocalActionKind, + }, ChannelStatus(ChannelStatusSnapshot), } @@ -562,4 +576,22 @@ mod tests { assert_eq!(CoinOfInterest::CurrentGame.label(), "Current game coin"); assert_eq!(CoinOfInterest::GamePayout.label(), "Game payout coin"); } + + #[test] + fn local_action_applied_uses_host_notification_wire_shape() { + let notification = GameNotification::LocalActionApplied { + id: GameID(7), + action: LocalActionKind::AcceptSettlement, + }; + let json = serde_json::to_value(notification).expect("serialize notification"); + assert_eq!( + json, + serde_json::json!({ + "LocalActionApplied": { + "id": 7, + "action": "accept_settlement", + } + }) + ); + } } diff --git a/src/session_phases/game_collection.rs b/src/session_phases/game_collection.rs index 4161f13c1..f2141657f 100644 --- a/src/session_phases/game_collection.rs +++ b/src/session_phases/game_collection.rs @@ -1,86 +1,126 @@ -use clvm_traits::{clvm_curried_args, ToClvm}; -use clvm_utils::CurriedProgram; +use std::cell::RefCell; use std::collections::BTreeMap; -use crate::common::load_clvm::{read_hex_puzzle, read_krunk_dict_dat}; +use crate::channel_state::game::Game; use crate::common::types::{AllocEncoder, GameType, Program}; use crate::session_phases::types::GameFactory; -/// Register all production games (calpoker, spacepoker, krunk). -/// -/// Under `cfg(test)`, also registers the `debug` factory used by simulator tests. -pub fn game_collection(allocator: &mut AllocEncoder) -> BTreeMap { - register_all(allocator) +include!(concat!(env!("OUT_DIR"), "/game_register.rs")); + +thread_local! { + static CACHED_PRODUCTION: RefCell> = const { RefCell::new(None) }; + static CACHED_WITH_TEST: RefCell> = const { RefCell::new(None) }; } -/// Alias for [`game_collection`]. -pub fn register_all(allocator: &mut AllocEncoder) -> BTreeMap { - let mut game_type_map = BTreeMap::new(); +#[derive(Clone, Default)] +pub struct RegisteredGameSet { + pub factories: BTreeMap, + pub package_ids: Vec<(String, GameType)>, +} - let calpoker_factory = read_hex_puzzle( - allocator, - "clsp/games/calpoker/calpoker_include_calpoker_factory.hex", - ) - .expect("should load"); - game_type_map.insert( - GameType(b"calpoker".to_vec()), - GameFactory { - program: Some(calpoker_factory.to_program()), - }, - ); +pub fn register_package( + allocator: &mut AllocEncoder, + key: &str, + factory: GameFactory, + probe: Program, + factories: &mut BTreeMap, + package_ids: &mut Vec<(String, GameType)>, +) { + let program = factory + .program + .as_ref() + .unwrap_or_else(|| panic!("package {key} factory program missing")) + .clone(); + let games = Game::run_factory(allocator, (*program).clone().into(), &probe) + .unwrap_or_else(|e| panic!("package {key} factory probe failed: {e:?}")); + if games.is_empty() { + panic!("package {key} factory returned no games"); + } + let id = GameType::from_hash(games[0].initial_validation_program_hash.clone()); + if factories.contains_key(&id) { + panic!("package {key} duplicate first-validator hash {id}"); + } + factories.insert(id.clone(), factory); + package_ids.push((key.to_string(), id)); +} - let spacepoker_factory = read_hex_puzzle( - allocator, - "clsp/games/spacepoker/spacepoker_include_spacepoker_factory.hex", - ) - .expect("should load"); - game_type_map.insert( - GameType(b"spacepoker".to_vec()), - GameFactory { - program: Some(spacepoker_factory.to_program()), - }, - ); +/// Register production games. Under `cfg(test)`, also register test packages. +pub fn game_collection(allocator: &mut AllocEncoder) -> BTreeMap { + register_games(allocator).factories +} - let krunk_factory_raw = read_hex_puzzle( - allocator, - "clsp/games/krunk/krunk_include_krunk_factory.hex", - ) - .expect("should load krunk factory"); - let (dict_pubkey, dict_tree) = - read_krunk_dict_dat(allocator, "clsp/games/krunk/krunk_signed_dict_tree.dat") - .expect("should load krunk dict dat"); - let krunk_factory_node = CurriedProgram { - program: krunk_factory_raw, - args: clvm_curried_args!(dict_pubkey, dict_tree), +fn with_cache(include_test: bool, f: impl FnOnce(&mut RegisteredGameSet) -> R) -> R { + let slot = if include_test { + &CACHED_WITH_TEST + } else { + &CACHED_PRODUCTION + }; + slot.with(|cell| { + let mut set = cell.borrow_mut().take().unwrap_or_default(); + let result = f(&mut set); + *cell.borrow_mut() = Some(set); + result + }) +} + +fn ensure_package( + allocator: &mut AllocEncoder, + key: &str, + set: &mut RegisteredGameSet, +) -> GameType { + if let Some((_, id)) = set.package_ids.iter().find(|(k, _)| k == key) { + return id.clone(); } - .to_clvm(allocator) - .expect("curry krunk factory"); - let krunk_factory = Program::from_nodeptr(allocator, krunk_factory_node).expect("ok"); - game_type_map.insert( - GameType(b"krunk".to_vec()), - GameFactory { - program: Some(krunk_factory.into()), - }, - ); + register_one_package(allocator, key, &mut set.factories, &mut set.package_ids); + set.package_ids + .iter() + .find(|(k, _)| k == key) + .map(|(_, id)| id.clone()) + .unwrap_or_else(|| panic!("package {key} did not register")) +} - #[cfg(test)] - { - let debug_game_raw = - read_hex_puzzle(allocator, "clsp/test/debug_game.hex").expect("should load"); - let debug_game_node = CurriedProgram { - program: debug_game_raw.clone(), - args: clvm_curried_args!("factory", ()), +fn ensure_all(allocator: &mut AllocEncoder, include_test: bool, set: &mut RegisteredGameSet) { + for key in production_package_keys() { + ensure_package(allocator, key, set); + } + if include_test { + for key in test_package_keys() { + ensure_package(allocator, key, set); } - .to_clvm(allocator) - .expect("cvt"); - let debug_game = Program::from_nodeptr(allocator, debug_game_node).expect("ok"); - game_type_map.insert( - GameType(b"debug".to_vec()), - GameFactory { - program: Some(debug_game.into()), - }, - ); } +} + +fn cached_register(allocator: &mut AllocEncoder, include_test: bool) -> RegisteredGameSet { + with_cache(include_test, |set| { + ensure_all(allocator, include_test, set); + set.clone() + }) +} + +/// Probe one production package into the process-wide cache. Idempotent. +pub fn warm_production_package( + allocator: &mut AllocEncoder, + key: &str, +) -> Result { + if !production_package_keys().contains(&key) { + return Err(format!("unknown production package {key}")); + } + Ok(with_cache(false, |set| ensure_package(allocator, key, set))) +} + +pub fn register_games(allocator: &mut AllocEncoder) -> RegisteredGameSet { + cached_register(allocator, cfg!(test)) +} + +pub fn game_type_for_package(allocator: &mut AllocEncoder, key: &str) -> GameType { + register_games(allocator) + .package_ids + .into_iter() + .find(|(k, _)| k == key) + .map(|(_, id)| id) + .unwrap_or_else(|| panic!("unknown game package {key}")) +} - game_type_map +pub fn production_package_ids(allocator: &mut AllocEncoder) -> Vec<(String, GameType)> { + cached_register(allocator, false).package_ids } diff --git a/src/session_phases/handler_base.rs b/src/session_phases/handler_base.rs index 5cc91237d..7afaff200 100644 --- a/src/session_phases/handler_base.rs +++ b/src/session_phases/handler_base.rs @@ -103,36 +103,10 @@ impl ChannelStateBase { } } - pub fn amount(&self) -> Amount { - self.channel_state - .as_ref() - .map(|ch| ch.amount(true)) - .unwrap_or_default() - } - - pub fn get_our_current_share(&self) -> Option { - self.channel_state - .as_ref() - .map(|ch| ch.my_out_of_game_balance()) - } - - pub fn get_their_current_share(&self) -> Option { - self.channel_state - .as_ref() - .map(|ch| ch.their_out_of_game_balance()) - } - pub fn get_reward_puzzle_hash(&self, env: &mut ChannelEnv<'_>) -> Result { self.channel_state()?.get_reward_puzzle_hash(env) } - pub fn get_game_state_id(&self, env: &mut ChannelEnv<'_>) -> Result, Error> { - if let Some(ch) = self.channel_state.as_ref() { - return ch.get_game_state_id(env).map(Some); - } - Ok(None) - } - pub fn has_potato(&self) -> bool { matches!(self.have_potato, PotatoState::Present) } diff --git a/src/session_phases/handshake_initiator.rs b/src/session_phases/handshake_initiator.rs index ee986eac5..a0bc70f5f 100644 --- a/src/session_phases/handshake_initiator.rs +++ b/src/session_phases/handshake_initiator.rs @@ -19,14 +19,16 @@ use crate::common::types::{ Hash, IntoErr, Program, Puzzle, PuzzleHash, Sha256Input, Sha256tree, Spend, SpendBundle, Timeout, }; -use crate::game_session::PeerLifecyclePhase; +use crate::game_session::{phase_operation_error, PeerLifecyclePhase}; use crate::session_phases::effects::{ - format_coin, ChannelStatus, ChannelStatusSnapshot, CoinOfInterest, Effect, + format_coin, ChannelStatus, ChannelStatusSnapshot, CoinOfInterest, Effect, FailedGameAction, + GameNotification, TimeoutClaimSemantic, }; use crate::session_phases::handshake::{ CoinSpendRequest, HandshakePayloadB, HandshakePayloadC, HandshakePayloadE, HandshakePayloadF, HandshakeStepInfo, HandshakeStepWithSpend, RawCoinCondition, }; +use crate::session_phases::proposal::GameProposal; use crate::session_phases::types::{ GameFactory, OffChainPhaseInit, PeerMessage, PotatoState, SpendWalletReceiver, }; @@ -633,12 +635,21 @@ impl SpendWalletReceiver for HandshakeInitiatorPhase { #[typetag::serde] impl PeerLifecyclePhase for HandshakeInitiatorPhase { + fn phase_name(&self) -> &'static str { + "handshake initiator phase" + } fn has_queued_message(&self) -> bool { !self.incoming_messages.is_empty() } fn process_queued_message(&mut self, env: &mut ChannelEnv<'_>) -> Result, Error> { HandshakeInitiatorPhase::process_queued_message(self, env) } + fn has_queued_action(&self) -> bool { + false + } + fn process_queued_action(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { + Ok(vec![]) + } fn received_message( &mut self, env: &mut ChannelEnv<'_>, @@ -704,6 +715,17 @@ impl PeerLifecyclePhase for HandshakeInitiatorPhase { "cheat_game not available during handshake".to_string(), )) } + #[cfg(test)] + fn self_accept_proposal( + &mut self, + _env: &mut ChannelEnv<'_>, + _game_id: &GameID, + ) -> Result, Error> { + Err(phase_operation_error( + self.phase_name(), + "self_accept_proposal", + )) + } fn take_next_phase(&mut self) -> Option> { self.replacement .take() @@ -734,6 +756,12 @@ impl PeerLifecyclePhase for HandshakeInitiatorPhase { fn handshake_finished(&self) -> bool { false } + fn is_on_chain(&self) -> bool { + false + } + fn start_handshake(&mut self, env: &mut ChannelEnv<'_>) -> Result, Error> { + self.start(env) + } fn channel_offer( &mut self, _env: &mut ChannelEnv<'_>, @@ -751,6 +779,16 @@ impl PeerLifecyclePhase for HandshakeInitiatorPhase { Ok(None) } + fn channel_transaction_completion( + &mut self, + _env: &mut ChannelEnv<'_>, + _bundle: &SpendBundle, + ) -> Result, Error> { + Err(phase_operation_error( + self.phase_name(), + "channel_transaction_completion", + )) + } fn provide_launcher_coin( &mut self, env: &mut ChannelEnv<'_>, @@ -812,6 +850,36 @@ impl PeerLifecyclePhase for HandshakeInitiatorPhase { self.channel_offer(env, bundle) .map(|effect| effect.into_iter().collect::>()) } + fn propose_games( + &mut self, + _env: &mut ChannelEnv<'_>, + _games: &[GameProposal], + ) -> Result<(Vec, Vec), Error> { + Err(phase_operation_error(self.phase_name(), "propose_games")) + } + fn accept_proposal( + &mut self, + _env: &mut ChannelEnv<'_>, + _game_id: &GameID, + ) -> Result, Error> { + Err(phase_operation_error(self.phase_name(), "accept_proposal")) + } + fn cancel_proposal( + &mut self, + _env: &mut ChannelEnv<'_>, + _game_id: &GameID, + ) -> Result, Error> { + Err(phase_operation_error(self.phase_name(), "cancel_proposal")) + } + fn shut_down(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { + Err(phase_operation_error(self.phase_name(), "shut_down")) + } + fn flush_pending_actions(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { + Ok(vec![]) + } + fn take_failed_queued_action(&mut self) -> Option<(GameID, FailedGameAction)> { + None + } fn channel_status_snapshot(&self) -> Option { if self.failed { return Some(ChannelStatusSnapshot { @@ -912,10 +980,58 @@ impl PeerLifecyclePhase for HandshakeInitiatorPhase { fn channel_state(&self) -> Result<&ChannelState, Error> { HandshakeInitiatorPhase::channel_state(self) } - fn as_any(&self) -> &dyn std::any::Any { - self + fn has_active_on_chain_games(&self) -> bool { + false + } + fn timeout_claim_submitted( + &mut self, + _semantic: TimeoutClaimSemantic, + ) -> Result, Error> { + Ok(None) + } + fn timeout_claim_rearmed( + &mut self, + _semantic: TimeoutClaimSemantic, + ) -> Result, Error> { + Ok(None) + } + #[cfg(test)] + fn corrupt_state_for_testing(&mut self, _new_sn: usize) -> Result<(), Error> { + Err(phase_operation_error( + self.phase_name(), + "corrupt_state_for_testing", + )) + } + #[cfg(test)] + fn force_unroll_spend_for_testing( + &self, + _env: &mut ChannelEnv<'_>, + ) -> Result { + Err(phase_operation_error( + self.phase_name(), + "force_unroll_spend_for_testing", + )) + } + #[cfg(test)] + fn last_channel_coin_spend_info_for_testing(&self) -> Option { + None + } + #[cfg(test)] + fn force_stale_unroll_spend_for_testing( + &self, + _env: &mut ChannelEnv<'_>, + _saved: &ChannelCoinSpendInfo, + ) -> Result { + Err(phase_operation_error( + self.phase_name(), + "force_stale_unroll_spend_for_testing", + )) + } + #[cfg(test)] + fn take_off_chain_phase_for_testing(&mut self) -> Option { + self.take_off_chain_phase() } - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self + fn get_game_coin(&self, _game_id: &GameID) -> Option { + None } } diff --git a/src/session_phases/handshake_receiver.rs b/src/session_phases/handshake_receiver.rs index c8d6bc624..4bde9327c 100644 --- a/src/session_phases/handshake_receiver.rs +++ b/src/session_phases/handshake_receiver.rs @@ -15,14 +15,16 @@ use crate::common::types::{ Amount, CoinID, CoinString, Error, GameID, GameType, GetCoinStringParts, Hash, IntoErr, Program, PuzzleHash, Sha256Input, Sha256tree, SpendBundle, Timeout, }; -use crate::game_session::PeerLifecyclePhase; +use crate::game_session::{phase_operation_error, PeerLifecyclePhase}; use crate::session_phases::effects::{ - format_coin, ChannelStatus, ChannelStatusSnapshot, CoinOfInterest, Effect, + format_coin, ChannelStatus, ChannelStatusSnapshot, CoinOfInterest, Effect, FailedGameAction, + GameNotification, TimeoutClaimSemantic, }; use crate::session_phases::handshake::{ CoinSpendRequest, HandshakePayloadB, HandshakePayloadD, HandshakePayloadE, HandshakePayloadF, HandshakeStepInfo, HandshakeStepWithSpend, RawCoinCondition, }; +use crate::session_phases::proposal::GameProposal; use crate::session_phases::types::{ GameFactory, OffChainPhaseInit, PeerMessage, PotatoState, SpendWalletReceiver, }; @@ -618,12 +620,21 @@ impl SpendWalletReceiver for HandshakeReceiverPhase { #[typetag::serde] impl PeerLifecyclePhase for HandshakeReceiverPhase { + fn phase_name(&self) -> &'static str { + "handshake receiver phase" + } fn has_queued_message(&self) -> bool { !self.incoming_messages.is_empty() } fn process_queued_message(&mut self, env: &mut ChannelEnv<'_>) -> Result, Error> { HandshakeReceiverPhase::process_queued_message(self, env) } + fn has_queued_action(&self) -> bool { + false + } + fn process_queued_action(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { + Ok(vec![]) + } fn received_message( &mut self, env: &mut ChannelEnv<'_>, @@ -689,6 +700,17 @@ impl PeerLifecyclePhase for HandshakeReceiverPhase { "cheat_game not available during handshake".to_string(), )) } + #[cfg(test)] + fn self_accept_proposal( + &mut self, + _env: &mut ChannelEnv<'_>, + _game_id: &GameID, + ) -> Result, Error> { + Err(phase_operation_error( + self.phase_name(), + "self_accept_proposal", + )) + } fn take_next_phase(&mut self) -> Option> { self.replacement .take() @@ -719,6 +741,19 @@ impl PeerLifecyclePhase for HandshakeReceiverPhase { fn handshake_finished(&self) -> bool { false } + fn is_on_chain(&self) -> bool { + false + } + fn start_handshake(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { + Ok(None) + } + fn channel_offer( + &mut self, + _env: &mut ChannelEnv<'_>, + _bundle: SpendBundle, + ) -> Result, Error> { + Err(phase_operation_error(self.phase_name(), "channel_offer")) + } fn channel_transaction_completion( &mut self, _env: &mut ChannelEnv<'_>, @@ -767,6 +802,36 @@ impl PeerLifecyclePhase for HandshakeReceiverPhase { self.channel_transaction_completion(env, &bundle) .map(|effect| effect.into_iter().collect::>()) } + fn propose_games( + &mut self, + _env: &mut ChannelEnv<'_>, + _games: &[GameProposal], + ) -> Result<(Vec, Vec), Error> { + Err(phase_operation_error(self.phase_name(), "propose_games")) + } + fn accept_proposal( + &mut self, + _env: &mut ChannelEnv<'_>, + _game_id: &GameID, + ) -> Result, Error> { + Err(phase_operation_error(self.phase_name(), "accept_proposal")) + } + fn cancel_proposal( + &mut self, + _env: &mut ChannelEnv<'_>, + _game_id: &GameID, + ) -> Result, Error> { + Err(phase_operation_error(self.phase_name(), "cancel_proposal")) + } + fn shut_down(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { + Err(phase_operation_error(self.phase_name(), "shut_down")) + } + fn flush_pending_actions(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { + Ok(vec![]) + } + fn take_failed_queued_action(&mut self) -> Option<(GameID, FailedGameAction)> { + None + } fn channel_status_snapshot(&self) -> Option { if self.failed { return Some(ChannelStatusSnapshot { @@ -859,10 +924,58 @@ impl PeerLifecyclePhase for HandshakeReceiverPhase { fn channel_state(&self) -> Result<&ChannelState, Error> { HandshakeReceiverPhase::channel_state(self) } - fn as_any(&self) -> &dyn std::any::Any { - self + fn has_active_on_chain_games(&self) -> bool { + false + } + fn timeout_claim_submitted( + &mut self, + _semantic: TimeoutClaimSemantic, + ) -> Result, Error> { + Ok(None) + } + fn timeout_claim_rearmed( + &mut self, + _semantic: TimeoutClaimSemantic, + ) -> Result, Error> { + Ok(None) + } + #[cfg(test)] + fn corrupt_state_for_testing(&mut self, _new_sn: usize) -> Result<(), Error> { + Err(phase_operation_error( + self.phase_name(), + "corrupt_state_for_testing", + )) + } + #[cfg(test)] + fn force_unroll_spend_for_testing( + &self, + _env: &mut ChannelEnv<'_>, + ) -> Result { + Err(phase_operation_error( + self.phase_name(), + "force_unroll_spend_for_testing", + )) + } + #[cfg(test)] + fn last_channel_coin_spend_info_for_testing(&self) -> Option { + None + } + #[cfg(test)] + fn force_stale_unroll_spend_for_testing( + &self, + _env: &mut ChannelEnv<'_>, + _saved: &ChannelCoinSpendInfo, + ) -> Result { + Err(phase_operation_error( + self.phase_name(), + "force_stale_unroll_spend_for_testing", + )) + } + #[cfg(test)] + fn take_off_chain_phase_for_testing(&mut self) -> Option { + self.take_off_chain_phase() } - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self + fn get_game_coin(&self, _game_id: &GameID) -> Option { + None } } diff --git a/src/session_phases/mod.rs b/src/session_phases/mod.rs index d876237dd..0485c2997 100644 --- a/src/session_phases/mod.rs +++ b/src/session_phases/mod.rs @@ -13,17 +13,18 @@ use crate::channel_state::types::{ use crate::channel_state::ChannelState; use crate::common::standard_coin::puzzle_for_synthetic_public_key; use crate::common::types::{ - Aggsig, Amount, CoinSpend, CoinString, Error, GameID, GameType, Hash, IntoErr, Program, - ProgramRef, PuzzleHash, Spend, SpendBundle, Timeout, + Aggsig, AllocEncoder, Amount, CoinSpend, CoinString, Error, GameID, GameType, Hash, IntoErr, + Program, ProgramRef, PuzzleHash, Spend, SpendBundle, Timeout, }; use crate::session_phases::effects::{ format_coin, CancelReason, ChannelStatus, ChannelStatusSnapshot, CoinOfInterest, Effect, - FailedGameAction, GameNotification, GameStatusKind, GameStatusOtherParams, SettlementOutcome, + FailedGameAction, GameNotification, GameStatusKind, GameStatusOtherParams, LocalActionKind, + SettlementOutcome, TimeoutClaimSemantic, }; use crate::shutdown::get_conditions_with_channel_state; use crate::utils::proper_list; -use crate::game_session::PeerLifecyclePhase; +use crate::game_session::{phase_operation_error, PeerLifecyclePhase}; use crate::session_phases::types::{ validate_new_move_action, BatchAction, FromLocalUI, GameAction, GameFactory, PeerMessage, PotatoState, WireGameSpec, WireProposalGroup, @@ -43,7 +44,7 @@ pub mod spend_channel_coin_phase; pub mod types; pub mod wallet_traits; -pub use game_collection::{game_collection, register_all}; +pub use game_collection::game_collection; pub use wallet_traits::{ChannelFundingWallet, SpendWalletReceiver, WalletSpendInterface}; fn serialize_game_type_map( @@ -137,13 +138,22 @@ pub struct OffChainPhase { Option>, } +fn failed_game_action_context(action: &GameAction) -> Option<(GameID, FailedGameAction)> { + match action { + GameAction::Move(id, ..) => Some((*id, FailedGameAction::MakeMove)), + GameAction::AcceptSettlement(id) => Some((*id, FailedGameAction::AcceptSettlement)), + GameAction::Cheat(id, ..) => Some((*id, FailedGameAction::Cheat)), + _ => None, + } +} + fn format_batch_action(action: &BatchAction) -> String { match action { BatchAction::ProposeGroup(group) => { format!( "ProposeGroup ids={:?} type={} timeout={}", group.members.iter().map(|m| m.game_id).collect::>(), - hex::encode(&group.start.game_type.0), + group.start.game_type, group.start.timeout, ) } @@ -238,6 +248,11 @@ impl OffChainPhase { env: &mut ChannelEnv<'_>, start: &GameProposal, ) -> Result, Error> { + if self.game_types.is_empty() { + // Restored handshake-era sessions may still serialize an empty map. + self.game_types = + crate::session_phases::game_collection::game_collection(env.allocator); + } let factory = self .game_types .get(&start.game_type) @@ -247,7 +262,18 @@ impl OffChainPhase { .as_ref() .ok_or_else(|| Error::StrErr("GameFactory program missing".to_string()))? .clone(); - game::Game::run_factory(env.allocator, program.into(), &start.parameters) + let games = game::Game::run_factory(env.allocator, program.into(), &start.parameters)?; + let first_hash = games + .first() + .map(|g| g.initial_validation_program_hash.clone()) + .ok_or_else(|| Error::StrErr("proposal factory returned no games".to_string()))?; + if &first_hash != start.game_type.hash() { + return Err(Error::StrErr(format!( + "factory for {} returned first validator hash {}, expected {}", + start.game_type, first_hash, start.game_type + ))); + } + Ok(games) } fn hydrate_wire_proposal_group( @@ -306,6 +332,12 @@ impl OffChainPhase { incoming_messages: VecDeque>, last_channel_coin_spend_info: Option, ) -> OffChainPhase { + let game_types = if game_types.is_empty() { + let mut allocator = AllocEncoder::new(); + crate::session_phases::game_collection::game_collection(&mut allocator) + } else { + game_types + }; OffChainPhase { initiator, have_potato, @@ -333,22 +365,6 @@ impl OffChainPhase { self.channel_spend_next_phase.take() } - pub fn amount(&self) -> Amount { - self.my_contribution.clone() + self.their_contribution.clone() - } - - pub fn get_our_current_share(&self) -> Option { - self.channel_state - .as_ref() - .map(|ch| ch.get_our_current_share()) - } - - pub fn get_their_current_share(&self) -> Option { - self.channel_state - .as_ref() - .map(|ch| ch.get_their_current_share()) - } - pub fn is_failed(&self) -> bool { false } @@ -735,6 +751,7 @@ impl OffChainPhase { initial_validation_program_hash: ivp_hash, initial_state, game_type: resolved_game_type, + parameters: wire.start.parameters.clone(), })); } } @@ -1033,13 +1050,10 @@ impl OffChainPhase { let mut clean_shutdown_data: Option> = None; let mut pending_shutdown: Option<(CoinString, ProgramRef)> = None; let mut deferred = VecDeque::new(); + let mut applied_actions = Vec::new(); while let Some(action) = self.game_action_queue.pop_front() { - self.last_failed_queued_action = match &action { - GameAction::Move(id, ..) => Some((*id, FailedGameAction::MakeMove)), - GameAction::AcceptSettlement(id) => Some((*id, FailedGameAction::AcceptSettlement)), - _ => None, - }; + self.last_failed_queued_action = failed_game_action_context(&action); match action { GameAction::Move(game_id, readable_move, new_entropy) => { let ch = self.channel_state_mut()?; @@ -1049,6 +1063,7 @@ impl OffChainPhase { Ok(move_result) => { batch_actions .push(BatchAction::Move(game_id, move_result.game_move)); + applied_actions.push((game_id, LocalActionKind::MakeMove)); } Err(Error::GameMoveRejected { tag, message }) => { effects.push(Effect::Notify(GameNotification::MoveRejected { @@ -1073,6 +1088,7 @@ impl OffChainPhase { let move_result = ch.send_move_no_finalize(env, &game_id, &readable_move, entropy)?; batch_actions.push(BatchAction::Move(game_id, move_result.game_move)); + applied_actions.push((game_id, LocalActionKind::Cheat)); } else { deferred.push_back(GameAction::Cheat(game_id, mover_share, entropy)); } @@ -1083,6 +1099,7 @@ impl OffChainPhase { ch.send_accept_settlement_no_finalize(&game_id)? }; batch_actions.push(BatchAction::AcceptSettlement(game_id, amount)); + applied_actions.push((game_id, LocalActionKind::AcceptSettlement)); } GameAction::QueuedProposalGroup(my_games, their_wire) => { let saved_channel = self.channel_state.clone(); @@ -1217,12 +1234,6 @@ impl OffChainPhase { pending_shutdown = Some((channel_coin.clone(), spend.solution.clone())); } - GameAction::SendPotato => { - return Err(Error::StrErr( - "SendPotato action is obsolete and must not appear in the queue" - .to_string(), - )); - } #[cfg(test)] GameAction::ForcedSelfAccept(game_id) => { let ch = self.channel_state_mut()?; @@ -1246,6 +1257,10 @@ impl OffChainPhase { ch.update_cached_unroll_state(env)? }; + effects.extend(applied_actions.into_iter().map(|(id, action)| { + Effect::Notify(GameNotification::LocalActionApplied { id, action }) + })); + { let ch = self.channel_state()?; effects.push(Effect::Log(make_send_log( @@ -1514,14 +1529,6 @@ impl OffChainPhase { let (_has_potato, effect) = self.send_potato_request_if_needed()?; Ok((false, effect.into_iter().collect())) } - - pub fn get_game_state_id(&mut self, env: &mut ChannelEnv<'_>) -> Result, Error> { - let player_ch = self.channel_state().ok(); - if let Some(player_ch) = player_ch { - return player_ch.get_game_state_id(env).map(Some); - } - Ok(None) - } } impl FromLocalUI for OffChainPhase { @@ -1759,12 +1766,21 @@ impl SpendWalletReceiver for OffChainPhase { #[typetag::serde] impl PeerLifecyclePhase for OffChainPhase { + fn phase_name(&self) -> &'static str { + "off-chain phase" + } fn has_queued_message(&self) -> bool { OffChainPhase::has_queued_message(self) } fn process_queued_message(&mut self, env: &mut ChannelEnv<'_>) -> Result, Error> { OffChainPhase::process_queued_message(self, env) } + fn has_queued_action(&self) -> bool { + false + } + fn process_queued_action(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { + Ok(vec![]) + } fn received_message( &mut self, env: &mut ChannelEnv<'_>, @@ -1842,9 +1858,52 @@ impl PeerLifecyclePhase for OffChainPhase { self.take_channel_spend_next_phase() .map(|h| h as Box) } + fn new_block(&mut self, _height: u64) -> Result, Error> { + Ok(vec![]) + } fn handshake_finished(&self) -> bool { OffChainPhase::handshake_finished(self) } + fn is_on_chain(&self) -> bool { + false + } + fn start_handshake(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { + Err(phase_operation_error(self.phase_name(), "start_handshake")) + } + fn channel_offer( + &mut self, + _env: &mut ChannelEnv<'_>, + _bundle: SpendBundle, + ) -> Result, Error> { + Ok(None) + } + fn channel_transaction_completion( + &mut self, + _env: &mut ChannelEnv<'_>, + _bundle: &SpendBundle, + ) -> Result, Error> { + Ok(None) + } + fn provide_launcher_coin( + &mut self, + _env: &mut ChannelEnv<'_>, + _launcher_coin: CoinString, + ) -> Result, Error> { + Err(phase_operation_error( + self.phase_name(), + "provide_launcher_coin", + )) + } + fn provide_coin_spend_bundle( + &mut self, + _env: &mut ChannelEnv<'_>, + _bundle: SpendBundle, + ) -> Result, Error> { + Err(phase_operation_error( + self.phase_name(), + "provide_coin_spend_bundle", + )) + } fn propose_games( &mut self, env: &mut ChannelEnv<'_>, @@ -1907,11 +1966,51 @@ impl PeerLifecyclePhase for OffChainPhase { fn channel_state(&self) -> Result<&ChannelState, Error> { OffChainPhase::channel_state(self) } - fn as_any(&self) -> &dyn std::any::Any { - self + fn wallet_callback_failed(&mut self, _reason: String) {} + fn has_active_on_chain_games(&self) -> bool { + false + } + fn timeout_claim_submitted( + &mut self, + _semantic: TimeoutClaimSemantic, + ) -> Result, Error> { + Ok(None) + } + fn timeout_claim_rearmed( + &mut self, + _semantic: TimeoutClaimSemantic, + ) -> Result, Error> { + Ok(None) + } + #[cfg(test)] + fn corrupt_state_for_testing(&mut self, new_sn: usize) -> Result<(), Error> { + OffChainPhase::corrupt_state_for_testing(self, new_sn) } - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self + #[cfg(test)] + fn force_unroll_spend_for_testing( + &self, + env: &mut ChannelEnv<'_>, + ) -> Result { + OffChainPhase::force_unroll_spend(self, env) + } + #[cfg(test)] + fn last_channel_coin_spend_info_for_testing(&self) -> Option { + self.get_last_channel_coin_spend_info().cloned() + } + #[cfg(test)] + fn force_stale_unroll_spend_for_testing( + &self, + env: &mut ChannelEnv<'_>, + saved: &ChannelCoinSpendInfo, + ) -> Result { + OffChainPhase::force_stale_unroll_spend(self, env, saved) + } + #[cfg(test)] + fn take_off_chain_phase_for_testing(&mut self) -> Option { + None + } + fn get_game_coin(&self, _game_id: &GameID) -> Option { + None } } @@ -1919,6 +2018,18 @@ impl PeerLifecyclePhase for OffChainPhase { mod atomic_group_tests { use super::*; + #[test] + fn queued_cheat_failure_keeps_game_action_context() { + assert_eq!( + failed_game_action_context(&GameAction::Cheat( + GameID(7), + Amount::default(), + Hash::default(), + )), + Some((GameID(7), FailedGameAction::Cheat)), + ); + } + fn member(id: u64) -> WireGameSpec { WireGameSpec { game_id: GameID(id), @@ -1937,7 +2048,7 @@ mod atomic_group_tests { fn group(members: Vec, group_id: GameID) -> WireProposalGroup { WireProposalGroup { start: GameProposal { - game_type: GameType(b"test".to_vec()), + game_type: GameType::from_hash(Hash::default()), timeout: Timeout::new(15), parameters: Program::from_bytes(&[0x80]), }, diff --git a/src/session_phases/on_chain.rs b/src/session_phases/on_chain.rs index 2406093c8..57c2fc494 100644 --- a/src/session_phases/on_chain.rs +++ b/src/session_phases/on_chain.rs @@ -3,23 +3,28 @@ use std::rc::Rc; use serde::{Deserialize, Serialize}; +#[cfg(test)] +use crate::channel_state::types::ChannelCoinSpendInfo; use crate::channel_state::types::ChannelEnv; use crate::channel_state::types::{ ChannelPrivateKeys, CoinSpentInformation, LiveGame, OnChainGameState, ReadableMove, }; +use crate::channel_state::ChannelState; use crate::common::types::{ - AllocEncoder, Amount, CoinCondition, CoinSpend, CoinString, Error, GameID, Hash, Program, - PuzzleHash, Sha256Input, Spend, SpendBundle, Timeout, + Amount, CoinCondition, CoinSpend, CoinString, Error, GameID, Hash, Program, PuzzleHash, Spend, + SpendBundle, Timeout, }; -use crate::game_session::PeerLifecyclePhase; +use crate::game_session::{phase_operation_error, PeerLifecyclePhase}; use crate::referee::types::{ GameMoveDetails, ParsedRefereeSolution, SlashOutcome, TheirTurnCoinSpentResult, }; use crate::referee::Referee; use crate::session_phases::effects::{ - format_coin, ChannelStatus, ChannelStatusSnapshot, CoinOfInterest, Effect, GameNotification, - GameStatusKind, GameStatusOtherParams, SettlementOutcome, TimeoutClaimSemantic, + format_coin, ChannelStatus, ChannelStatusSnapshot, CoinOfInterest, Effect, FailedGameAction, + GameNotification, GameStatusKind, GameStatusOtherParams, LocalActionKind, SettlementOutcome, + TimeoutClaimSemantic, }; +use crate::session_phases::proposal::GameProposal; use crate::session_phases::types::{validate_new_move_action, GameAction, PotatoState}; use std::borrow::Borrow; @@ -102,6 +107,7 @@ pub struct OnChainPhaseArgs { fn on_chain_move_submission_effects( game_id: GameID, + action: LocalActionKind, current_coin: &CoinString, transaction: Spend, ) -> Vec { @@ -116,6 +122,10 @@ fn on_chain_move_submission_effects( }, None, ), + Effect::Notify(GameNotification::LocalActionApplied { + id: game_id, + action, + }), Effect::Notify(GameNotification::GameStatus { id: game_id, status: GameStatusKind::PlayingMove, @@ -232,20 +242,6 @@ impl OnChainPhase { Some(Effect::Notify(notification)) } - // --- Getters (duplicated from ChannelState) --- - - pub fn amount(&self) -> Amount { - self.my_allocated_balance.clone() + self.their_allocated_balance.clone() - } - - pub fn get_our_current_share(&self) -> Option { - None - } - - pub fn get_their_current_share(&self) -> Option { - None - } - pub fn get_reward_puzzle_hash(&self) -> PuzzleHash { self.reward_puzzle_hash.clone() } @@ -350,15 +346,6 @@ impl OnChainPhase { Ok(self.live_games[game_idx].enable_cheating(make_move, mover_share)) } - pub fn get_game_state_id(&self, allocator: &mut AllocEncoder) -> Result, Error> { - let mut bytes: Vec = Vec::with_capacity(self.live_games.len() * 32); - for l in self.live_games.iter() { - let ph = l.current_puzzle_hash(allocator)?; - bytes.extend_from_slice(ph.bytes()); - } - Ok(Some(Sha256Input::Bytes(&bytes).hash())) - } - // --- Game coin tracking --- pub fn get_game_coin(&self, game_id: &GameID) -> Option { @@ -1546,6 +1533,7 @@ impl OnChainPhase { game_id: GameID, readable_move: ReadableMove, entropy: Hash, + action: LocalActionKind, ) -> Result, Error> { let my_turn = self.my_move_in_game(&game_id); if my_turn.is_none() { @@ -1584,12 +1572,18 @@ impl OnChainPhase { if !has_pending_slash && move_result.basic.mover_share == game_amount { self.restore_game_state(&game_id, pre_referee, pre_last_ph)?; self.game_map.retain(|_, def| def.game_id != game_id); - return Ok(vec![Effect::Notify(GameNotification::game_settled( - game_id, - SettlementOutcome::ForfeitedSkippedReveal, - Amount::default(), - None, - ))]); + return Ok(vec![ + Effect::Notify(GameNotification::LocalActionApplied { + id: game_id, + action, + }), + Effect::Notify(GameNotification::game_settled( + game_id, + SettlementOutcome::ForfeitedSkippedReveal, + Amount::default(), + None, + )), + ]); } let (post_referee, post_last_ph) = self.save_game_state(&game_id)?; @@ -1618,6 +1612,7 @@ impl OnChainPhase { Ok(on_chain_move_submission_effects( game_id, + action, current_coin, transaction, )) @@ -1652,7 +1647,14 @@ impl OnChainPhase { ))); } Ok(self - .do_on_chain_move(env, ¤t_coin, game_id, readable_move, hash)? + .do_on_chain_move( + env, + ¤t_coin, + game_id, + readable_move, + hash, + LocalActionKind::MakeMove, + )? .into_iter() .collect()) } @@ -1675,7 +1677,14 @@ impl OnChainPhase { let readable_move = ReadableMove::from_program(Rc::new(Program::from_bytes(&[0x80]))); Ok(self - .do_on_chain_move(env, ¤t_coin, game_id, readable_move, entropy)? + .do_on_chain_move( + env, + ¤t_coin, + game_id, + readable_move, + entropy, + LocalActionKind::Cheat, + )? .into_iter() .collect()) } else if my_turn.is_none() { @@ -1704,12 +1713,18 @@ impl OnChainPhase { let our_share = self.get_game_our_current_share(&game_id); if matches!(our_share, Ok(ref s) if *s == Amount::default()) { self.game_map.remove(¤t_coin); - return Ok(vec![Effect::Notify(GameNotification::game_settled( - game_id, - SettlementOutcome::ForfeitedWeAccepted, - Amount::default(), - None, - ))]); + return Ok(vec![ + Effect::Notify(GameNotification::LocalActionApplied { + id: game_id, + action: LocalActionKind::AcceptSettlement, + }), + Effect::Notify(GameNotification::game_settled( + game_id, + SettlementOutcome::ForfeitedWeAccepted, + Amount::default(), + None, + )), + ]); } } let gt = self @@ -1736,6 +1751,10 @@ impl OnChainPhase { if let Some(def) = self.game_map.get_mut(¤t_coin) { def.timeout_claim_armed = true; } + effects.push(Effect::Notify(GameNotification::LocalActionApplied { + id: game_id, + action: LocalActionKind::AcceptSettlement, + })); effects.push(Effect::Notify(GameNotification::GameStatus { id: game_id, status: GameStatusKind::FinishingWaitingTimeout, @@ -1750,9 +1769,6 @@ impl OnChainPhase { Ok(effects) } GameAction::CleanShutdown => Ok(Vec::new()), - GameAction::SendPotato => Err(Error::StrErr( - "SendPotato action is obsolete and must not appear in the queue".to_string(), - )), GameAction::QueuedProposalGroup(_, _) | GameAction::QueuedAcceptProposal(_) | GameAction::QueuedCancelProposal(_) @@ -1888,6 +1904,9 @@ impl OnChainPhase { #[typetag::serde] impl PeerLifecyclePhase for OnChainPhase { + fn phase_name(&self) -> &'static str { + "on-chain phase" + } fn has_queued_message(&self) -> bool { OnChainPhase::has_queued_message(self) } @@ -1964,10 +1983,107 @@ impl PeerLifecyclePhase for OnChainPhase { ) -> Result, Error> { OnChainPhase::cheat_game(self, env, game_id, mover_share, entropy) } + #[cfg(test)] + fn self_accept_proposal( + &mut self, + _env: &mut ChannelEnv<'_>, + _game_id: &GameID, + ) -> Result, Error> { + Err(phase_operation_error( + self.phase_name(), + "self_accept_proposal", + )) + } fn take_next_phase(&mut self) -> Option> { None } + fn new_block(&mut self, _height: u64) -> Result, Error> { + Ok(vec![]) + } + fn handshake_finished(&self) -> bool { + true + } + fn is_on_chain(&self) -> bool { + true + } + fn start_handshake(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { + Err(phase_operation_error(self.phase_name(), "start_handshake")) + } + fn channel_offer( + &mut self, + _env: &mut ChannelEnv<'_>, + _bundle: SpendBundle, + ) -> Result, Error> { + Ok(None) + } + fn channel_transaction_completion( + &mut self, + _env: &mut ChannelEnv<'_>, + _bundle: &SpendBundle, + ) -> Result, Error> { + Ok(None) + } + fn provide_launcher_coin( + &mut self, + _env: &mut ChannelEnv<'_>, + _launcher_coin: CoinString, + ) -> Result, Error> { + Err(phase_operation_error( + self.phase_name(), + "provide_launcher_coin", + )) + } + fn provide_coin_spend_bundle( + &mut self, + _env: &mut ChannelEnv<'_>, + _bundle: SpendBundle, + ) -> Result, Error> { + Err(phase_operation_error( + self.phase_name(), + "provide_coin_spend_bundle", + )) + } + fn propose_games( + &mut self, + _env: &mut ChannelEnv<'_>, + _games: &[GameProposal], + ) -> Result<(Vec, Vec), Error> { + Err(phase_operation_error(self.phase_name(), "propose_games")) + } + fn accept_proposal( + &mut self, + _env: &mut ChannelEnv<'_>, + _game_id: &GameID, + ) -> Result, Error> { + Err(phase_operation_error(self.phase_name(), "accept_proposal")) + } + fn cancel_proposal( + &mut self, + _env: &mut ChannelEnv<'_>, + _game_id: &GameID, + ) -> Result, Error> { + Err(phase_operation_error(self.phase_name(), "cancel_proposal")) + } + fn shut_down(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { + Err(phase_operation_error(self.phase_name(), "shut_down")) + } + fn go_on_chain( + &mut self, + _env: &mut ChannelEnv<'_>, + _got_error: bool, + ) -> Result, Error> { + Ok(vec![]) + } + fn flush_pending_actions(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { + Ok(vec![]) + } + fn take_failed_queued_action(&mut self) -> Option<(GameID, FailedGameAction)> { + None + } + fn channel_state(&self) -> Result<&ChannelState, Error> { + Err(phase_operation_error(self.phase_name(), "channel_state")) + } fn channel_status_snapshot(&self) -> Option { let state = if self.advisory.is_some() { @@ -2021,11 +2137,67 @@ impl PeerLifecyclePhase for OnChainPhase { // the transaction manager must keep polling during that interval. !self.game_map.is_empty() } - fn as_any(&self) -> &dyn std::any::Any { - self + fn wallet_callback_failed(&mut self, _reason: String) {} + fn timeout_claim_submitted( + &mut self, + semantic: TimeoutClaimSemantic, + ) -> Result, Error> { + Ok(match semantic { + TimeoutClaimSemantic::ChannelTimeoutFinish => None, + TimeoutClaimSemantic::GameOpponentTurn { id } + | TimeoutClaimSemantic::GameFinishTimeout { id } => self.timeout_claim_status(id, true), + }) + } + fn timeout_claim_rearmed( + &mut self, + semantic: TimeoutClaimSemantic, + ) -> Result, Error> { + Ok(match semantic { + TimeoutClaimSemantic::ChannelTimeoutFinish => None, + TimeoutClaimSemantic::GameOpponentTurn { id } + | TimeoutClaimSemantic::GameFinishTimeout { id } => { + self.timeout_claim_status(id, false) + } + }) + } + #[cfg(test)] + fn corrupt_state_for_testing(&mut self, _new_sn: usize) -> Result<(), Error> { + Err(phase_operation_error( + self.phase_name(), + "corrupt_state_for_testing", + )) + } + #[cfg(test)] + fn force_unroll_spend_for_testing( + &self, + _env: &mut ChannelEnv<'_>, + ) -> Result { + Err(phase_operation_error( + self.phase_name(), + "force_unroll_spend_for_testing", + )) } - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self + #[cfg(test)] + fn last_channel_coin_spend_info_for_testing(&self) -> Option { + None + } + #[cfg(test)] + fn force_stale_unroll_spend_for_testing( + &self, + _env: &mut ChannelEnv<'_>, + _saved: &ChannelCoinSpendInfo, + ) -> Result { + Err(phase_operation_error( + self.phase_name(), + "force_stale_unroll_spend_for_testing", + )) + } + #[cfg(test)] + fn take_off_chain_phase_for_testing(&mut self) -> Option { + None + } + fn get_game_coin(&self, game_id: &GameID) -> Option { + OnChainPhase::get_game_coin(self, game_id) } } @@ -2035,12 +2207,20 @@ mod tests { #[test] fn on_chain_move_submission_precedes_playing_move_notification() { - let effects = - on_chain_move_submission_effects(GameID(7), &CoinString::default(), Spend::default()); + let effects = on_chain_move_submission_effects( + GameID(7), + LocalActionKind::MakeMove, + &CoinString::default(), + Spend::default(), + ); assert!(matches!( effects.as_slice(), [ Effect::SpendTransaction(_, _), + Effect::Notify(GameNotification::LocalActionApplied { + id: GameID(7), + action: LocalActionKind::MakeMove, + }), Effect::Notify(GameNotification::GameStatus { status: GameStatusKind::PlayingMove, .. diff --git a/src/session_phases/spend_channel_coin_phase.rs b/src/session_phases/spend_channel_coin_phase.rs index eb982767d..3a9a18244 100644 --- a/src/session_phases/spend_channel_coin_phase.rs +++ b/src/session_phases/spend_channel_coin_phase.rs @@ -12,10 +12,10 @@ use crate::common::types::{ chia_dialect, Aggsig, Amount, CoinCondition, CoinSpend, CoinString, Error, GameID, Hash, IntoErr, Program, ProgramRef, PuzzleHash, Spend, SpendBundle, Timeout, MAX_BLOCK_COST_CLVM, }; -use crate::game_session::PeerLifecyclePhase; +use crate::game_session::{phase_operation_error, PeerLifecyclePhase}; use crate::session_phases::effects::{ format_coin, CancelReason, ChannelSemanticPhase, ChannelStatus, ChannelStatusSnapshot, - CoinOfInterest, Effect, GameNotification, GameStatusKind, SettlementOutcome, + CoinOfInterest, Effect, FailedGameAction, GameNotification, GameStatusKind, SettlementOutcome, TimeoutClaimSemantic, UnrollInitiator, }; use crate::session_phases::handler_base::{ @@ -24,6 +24,7 @@ use crate::session_phases::handler_base::{ use crate::session_phases::on_chain::{ OnChainPhase, OnChainPhaseArgs, PendingMoveKind, PendingMoveSavedState, }; +use crate::session_phases::proposal::GameProposal; use crate::session_phases::types::{ validate_new_move_action, GameAction, PotatoState, SpendWalletReceiver, }; @@ -182,17 +183,6 @@ impl SpendChannelCoinPhase { } } - // --- Delegated query methods --- - - pub fn amount(&self) -> Amount { - self.base.amount() - } - pub fn get_our_current_share(&self) -> Option { - self.base.get_our_current_share() - } - pub fn get_their_current_share(&self) -> Option { - self.base.get_their_current_share() - } pub fn has_potato(&self) -> bool { self.base.has_potato() } @@ -201,10 +191,6 @@ impl SpendChannelCoinPhase { self.base.get_reward_puzzle_hash(env) } - pub fn get_game_state_id(&mut self, env: &mut ChannelEnv<'_>) -> Result, Error> { - self.base.get_game_state_id(env) - } - pub fn take_next_phase(&mut self) -> Option> { self.replacement.take() } @@ -1141,12 +1127,21 @@ impl SpendWalletReceiver for SpendChannelCoinPhase { #[typetag::serde] impl PeerLifecyclePhase for SpendChannelCoinPhase { + fn phase_name(&self) -> &'static str { + "channel-spend phase" + } fn has_queued_message(&self) -> bool { SpendChannelCoinPhase::has_queued_message(self) } fn process_queued_message(&mut self, env: &mut ChannelEnv<'_>) -> Result, Error> { SpendChannelCoinPhase::process_queued_message(self, env) } + fn has_queued_action(&self) -> bool { + false + } + fn process_queued_action(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { + Ok(vec![]) + } fn received_message( &mut self, env: &mut ChannelEnv<'_>, @@ -1201,9 +1196,90 @@ impl PeerLifecyclePhase for SpendChannelCoinPhase { ) -> Result, Error> { SpendChannelCoinPhase::cheat_game(self, env, game_id, mover_share, entropy) } + #[cfg(test)] + fn self_accept_proposal( + &mut self, + _env: &mut ChannelEnv<'_>, + _game_id: &GameID, + ) -> Result, Error> { + Err(phase_operation_error( + self.phase_name(), + "self_accept_proposal", + )) + } fn take_next_phase(&mut self) -> Option> { SpendChannelCoinPhase::take_next_phase(self).map(|oc| oc as Box) } + fn new_block(&mut self, _height: u64) -> Result, Error> { + Ok(vec![]) + } + fn handshake_finished(&self) -> bool { + true + } + fn is_on_chain(&self) -> bool { + false + } + fn start_handshake(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { + Err(phase_operation_error(self.phase_name(), "start_handshake")) + } + fn channel_offer( + &mut self, + _env: &mut ChannelEnv<'_>, + _bundle: SpendBundle, + ) -> Result, Error> { + Ok(None) + } + fn channel_transaction_completion( + &mut self, + _env: &mut ChannelEnv<'_>, + _bundle: &SpendBundle, + ) -> Result, Error> { + Ok(None) + } + fn provide_launcher_coin( + &mut self, + _env: &mut ChannelEnv<'_>, + _launcher_coin: CoinString, + ) -> Result, Error> { + Err(phase_operation_error( + self.phase_name(), + "provide_launcher_coin", + )) + } + fn provide_coin_spend_bundle( + &mut self, + _env: &mut ChannelEnv<'_>, + _bundle: SpendBundle, + ) -> Result, Error> { + Err(phase_operation_error( + self.phase_name(), + "provide_coin_spend_bundle", + )) + } + fn propose_games( + &mut self, + _env: &mut ChannelEnv<'_>, + _games: &[GameProposal], + ) -> Result<(Vec, Vec), Error> { + Err(phase_operation_error(self.phase_name(), "propose_games")) + } + fn accept_proposal( + &mut self, + _env: &mut ChannelEnv<'_>, + _game_id: &GameID, + ) -> Result, Error> { + Err(phase_operation_error(self.phase_name(), "accept_proposal")) + } + fn cancel_proposal( + &mut self, + _env: &mut ChannelEnv<'_>, + _game_id: &GameID, + ) -> Result, Error> { + Err(phase_operation_error(self.phase_name(), "cancel_proposal")) + } + fn shut_down(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { + Err(phase_operation_error(self.phase_name(), "shut_down")) + } fn go_on_chain( &mut self, env: &mut ChannelEnv<'_>, @@ -1211,6 +1287,12 @@ impl PeerLifecyclePhase for SpendChannelCoinPhase { ) -> Result, Error> { SpendChannelCoinPhase::go_on_chain(self, env) } + fn flush_pending_actions(&mut self, _env: &mut ChannelEnv<'_>) -> Result, Error> { + Ok(vec![]) + } + fn take_failed_queued_action(&mut self) -> Option<(GameID, FailedGameAction)> { + None + } fn channel_status_snapshot(&self) -> Option { struct SpendSnapshotView { state: ChannelStatus, @@ -1340,11 +1422,63 @@ impl PeerLifecyclePhase for SpendChannelCoinPhase { fn channel_state(&self) -> Result<&ChannelState, Error> { self.base.channel_state() } - fn as_any(&self) -> &dyn std::any::Any { - self + fn wallet_callback_failed(&mut self, _reason: String) {} + fn has_active_on_chain_games(&self) -> bool { + false + } + fn timeout_claim_submitted( + &mut self, + semantic: TimeoutClaimSemantic, + ) -> Result, Error> { + if matches!(semantic, TimeoutClaimSemantic::ChannelTimeoutFinish) { + SpendChannelCoinPhase::timeout_claim_submitted(self, semantic); + } + Ok(None) } - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self + fn timeout_claim_rearmed( + &mut self, + semantic: TimeoutClaimSemantic, + ) -> Result, Error> { + if matches!(semantic, TimeoutClaimSemantic::ChannelTimeoutFinish) { + SpendChannelCoinPhase::timeout_claim_rearmed(self, semantic); + } + Ok(None) + } + #[cfg(test)] + fn corrupt_state_for_testing(&mut self, _new_sn: usize) -> Result<(), Error> { + Err(phase_operation_error( + self.phase_name(), + "corrupt_state_for_testing", + )) + } + #[cfg(test)] + fn force_unroll_spend_for_testing( + &self, + env: &mut ChannelEnv<'_>, + ) -> Result { + SpendChannelCoinPhase::force_unroll_spend(self, env) + } + #[cfg(test)] + fn last_channel_coin_spend_info_for_testing(&self) -> Option { + self.last_channel_coin_spend_info.clone() + } + #[cfg(test)] + fn force_stale_unroll_spend_for_testing( + &self, + _env: &mut ChannelEnv<'_>, + _saved: &ChannelCoinSpendInfo, + ) -> Result { + Err(phase_operation_error( + self.phase_name(), + "force_stale_unroll_spend_for_testing", + )) + } + #[cfg(test)] + fn take_off_chain_phase_for_testing(&mut self) -> Option { + None + } + fn get_game_coin(&self, _game_id: &GameID) -> Option { + None } } @@ -1523,12 +1657,26 @@ mod tests { replacement: None, }; - assert!(phase.timeout_claim_submitted(TimeoutClaimSemantic::ChannelTimeoutFinish)); + assert!( + ::timeout_claim_submitted( + &mut phase, + TimeoutClaimSemantic::ChannelTimeoutFinish, + ) + .expect("timeout submission") + .is_none() + ); assert_eq!( phase.channel_status_snapshot().unwrap().semantic_phase, Some(ChannelSemanticPhase::FinishingSpending) ); - assert!(phase.timeout_claim_rearmed(TimeoutClaimSemantic::ChannelTimeoutFinish)); + assert!( + ::timeout_claim_rearmed( + &mut phase, + TimeoutClaimSemantic::ChannelTimeoutFinish, + ) + .expect("timeout rearm") + .is_none() + ); assert_eq!( phase.channel_status_snapshot().unwrap().semantic_phase, Some(ChannelSemanticPhase::FinishingWaitingTimeout) diff --git a/src/session_phases/types.rs b/src/session_phases/types.rs index 7f4ef1610..32eabd492 100644 --- a/src/session_phases/types.rs +++ b/src/session_phases/types.rs @@ -151,7 +151,6 @@ pub enum GameAction { #[serde(rename = "AcceptSettlement")] AcceptSettlement(GameID), CleanShutdown, - SendPotato, QueuedProposalGroup(Vec>, WireProposalGroup), QueuedAcceptProposal(GameID), QueuedCancelProposal(GameID), @@ -191,7 +190,6 @@ impl std::fmt::Debug for GameAction { GameAction::Move(gi, rm, h) => write!(formatter, "Move({gi:?},{rm:?},{h:?})"), GameAction::AcceptSettlement(gi) => write!(formatter, "AcceptSettlement({gi:?})"), GameAction::CleanShutdown => write!(formatter, "CleanShutdown"), - GameAction::SendPotato => write!(formatter, "SendPotato"), GameAction::QueuedProposalGroup(_, _) => write!(formatter, "QueuedProposalGroup(..)"), GameAction::QueuedAcceptProposal(gi) => { write!(formatter, "QueuedAcceptProposal({gi:?})") diff --git a/src/simulator/mod.rs b/src/simulator/mod.rs index 2121cee5e..bee1562d2 100644 --- a/src/simulator/mod.rs +++ b/src/simulator/mod.rs @@ -28,46 +28,26 @@ use crate::common::types::{ use crate::utils::map_m; +#[cfg(test)] +use crate::common::types::divmod::test_funs as divmod_tests; #[cfg(test)] use crate::simulator::tests::session_phases_sim::test_funs as session_phases_sim_tests; #[cfg(test)] use crate::simulator::tests::simulator_tests::test_funs as simulator_tests; #[cfg(test)] -use crate::test_support::calpoker_sim::test_funs as calpoker_tests; -#[cfg(test)] -use crate::test_support::krunk_sim::test_funs as krunk_sim_tests; -#[cfg(test)] -use crate::test_support::spacepoker_sim::test_funs as spacepoker_tests; - -#[cfg(test)] -use crate::common::types::divmod::test_funs as divmod_tests; -#[cfg(test)] -use crate::test_support::debug_game::test_funs as debug_game_tests; -#[cfg(test)] use crate::test_support::peer::peer_harness::test_funs as peer_harness_tests; #[cfg(test)] -use crate::tests::calpoker_handlers::test_funs as calpoker_handler_tests; -#[cfg(test)] -use crate::tests::calpoker_validation::test_funs as calpoker_validation_tests; -#[cfg(test)] use crate::tests::channel_state::test_funs as channel_handler_tests; #[cfg(test)] use crate::tests::chialisp::test_funs as chialisp_tests; #[cfg(test)] -use crate::tests::dict_tree_lookup::test_funs as dict_tree_lookup_tests; -#[cfg(test)] -use crate::tests::krunk_handlers::test_funs as krunk_handler_tests; -#[cfg(test)] -use crate::tests::krunk_validation::test_funs as krunk_validation_tests; -#[cfg(test)] use crate::tests::referee_conditions::test_funs as referee_conditions_tests; #[cfg(test)] -use crate::tests::spacepoker_handlers::test_funs as spacepoker_handler_tests; -#[cfg(test)] -use crate::tests::spacepoker_validation::test_funs as spacepoker_validation_tests; -#[cfg(test)] use crate::tests::standard_coin::test_funs as standard_coin_tests; +#[cfg(test)] +include!(concat!(env!("OUT_DIR"), "/game_package_test_funs.rs")); + #[derive(Debug, Clone)] pub struct IncludeTransactionResult { pub code: u32, @@ -171,7 +151,7 @@ impl SimulatorState { true, ); - let pending: Vec = self.mempool.drain(..).collect(); + let pending = std::mem::take(&mut self.mempool); for spend in pending { self.confirmed_spend_fingerprints.insert(spend.fingerprint); for removal in &spend.removals { @@ -1034,21 +1014,11 @@ pub fn run_simulation_tests() { divmod_tests(), standard_coin_tests(), chialisp_tests(), - calpoker_validation_tests(), - spacepoker_validation_tests(), - krunk_validation_tests(), - dict_tree_lookup_tests(), - spacepoker_handler_tests(), - calpoker_handler_tests(), - krunk_handler_tests(), + game_package_test_funs(), channel_handler_tests(), referee_conditions_tests(), - debug_game_tests(), peer_harness_tests(), simulator_tests(), - calpoker_tests(), - spacepoker_tests(), - krunk_sim_tests(), session_phases_sim_tests(), ]; diff --git a/src/simulator/tests/session_phases_sim.rs b/src/simulator/tests/session_phases_sim.rs index 9298f026b..657babe03 100644 --- a/src/simulator/tests/session_phases_sim.rs +++ b/src/simulator/tests/session_phases_sim.rs @@ -12,13 +12,13 @@ use crate::common::constants::{AGG_SIG_ME_ADDITIONAL_DATA, CREATE_COIN, SINGLETO use crate::common::standard_coin::{standard_solution_partial, ChiaIdentity}; use crate::common::types::{atom_from_clvm, i64_from_atom, usize_from_atom}; use crate::common::types::{ - AllocEncoder, Amount, CoinID, CoinSpend, CoinString, Error, GameID, GameType, Hash, IntoErr, - PrivateKey, Program, PuzzleHash, Spend, SpendBundle, Timeout, + AllocEncoder, Amount, CoinID, CoinSpend, CoinString, Error, GameID, Hash, IntoErr, PrivateKey, + Program, PuzzleHash, Spend, SpendBundle, Timeout, }; use crate::game_session::{GameSession, GameSessionConfig, MessagePeerQueue, MessagePipe}; use crate::session_phases::effects::{ CancelReason, ChannelStatus, ChannelStatusSnapshot, GameNotification, GameSessionEvent, - GameStatusKind, SettlementOutcome, UnrollInitiator, + GameStatusKind, LocalActionKind, SettlementOutcome, UnrollInitiator, }; use crate::session_phases::game_collection; use crate::session_phases::handshake::CoinSpendRequest; @@ -521,6 +521,9 @@ fn event_shape(actual: &TestEvent) -> String { GameNotification::InsufficientBalance { id, our_balance_short, their_balance_short } => format!("Notif(InsufficientBalance(id={id:?},ours={our_balance_short},theirs={their_balance_short}))"), GameNotification::ActionFailed { reason, .. } => format!("Notif(ActionFailed(reason={reason}))"), GameNotification::MoveRejected { id, tag, message } => format!("Notif(MoveRejected(id={id:?},tag={tag},message={message}))"), + GameNotification::LocalActionApplied { id, action } => { + format!("Notif(LocalActionApplied(id={id:?},action={action:?}))") + } GameNotification::ChannelStatus(ChannelStatusSnapshot { state, .. }) => format!("Notif(ChannelStatus(state={state:?}))"), }, } @@ -812,6 +815,10 @@ impl ToLocalUI for LocalTestUIReceiver { self.events .push(TestEvent::Notification(notification.clone())); } + GameNotification::LocalActionApplied { .. } => { + self.assert_channel_created("local_action_applied"); + self.notifications.push(notification.clone()); + } GameNotification::ChannelStatus(ChannelStatusSnapshot { state, .. }) => { if matches!(state, ChannelStatus::Active) { self.channel_created = true; @@ -914,7 +921,7 @@ fn run_game_container_with_action_list_with_success_predicate( rng: &mut ChaCha8Rng, private_keys: [ChannelPrivateKeys; 2], identities: &[ChiaIdentity], - game_type: &[u8], + package_key: &str, extras: &Program, moves_input: &[SimScriptAction], pred: GameRunEarlySuccessPredicate, @@ -1002,7 +1009,7 @@ fn run_game_container_with_action_list_with_success_predicate( allocator, rng, identities, - game_type, + package_key, extras, moves_input, pred, @@ -1343,7 +1350,7 @@ pub fn run_calpoker_container_with_action_list_with_success_predicate( &mut rng, private_keys, &identities, - b"calpoker", + "calpoker", &Program::from_hex("80")?, moves, predicate, @@ -1395,7 +1402,7 @@ pub fn run_spacepoker_container_with_action_list_with_seed( &mut rng, private_keys, &identities, - b"spacepoker", + "spacepoker", &spacepoker_parameters, moves, predicate, @@ -1430,7 +1437,7 @@ pub fn run_krunk_container_with_action_list_with_success_predicate( &mut rng, private_keys, &identities, - b"krunk", + "krunk", &Program::from_hex("64")?, moves, predicate, @@ -1896,7 +1903,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, private_keys, &identities, - b"calpoker", + "calpoker", &Program::from_hex("80").unwrap(), &moves, Some(&|_, cradles| cradles[0].is_on_chain() && cradles[1].is_on_chain()), @@ -2621,7 +2628,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, sim_setup.private_keys.clone(), &sim_setup.identities, - b"debug", + "debug", &sim_setup.args_program.clone(), &sim_setup.game_actions, None, @@ -2703,7 +2710,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, sim_setup.private_keys.clone(), &sim_setup.identities, - b"debug", + "debug", &sim_setup.args_program.clone(), &sim_setup.game_actions, None, @@ -2788,7 +2795,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, sim_setup.private_keys.clone(), &sim_setup.identities, - b"debug", + "debug", &sim_setup.args_program.clone(), &sim_setup.game_actions, None, @@ -2878,7 +2885,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, sim_setup.private_keys.clone(), &sim_setup.identities, - b"debug", + "debug", &sim_setup.args_program.clone(), &sim_setup.game_actions, None, @@ -2959,14 +2966,14 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { let mut sim_setup = setup_debug_test(&mut allocator, &mut rng, &moves).expect("ok"); add_debug_test_accept_shutdown(&mut sim_setup, 20, 1); - let game_type: &[u8] = b"debug"; + let package_key: &str = "debug"; let mut outcome = run_game_container_with_action_list_with_success_predicate( &mut allocator, &mut rng, sim_setup.private_keys.clone(), &sim_setup.identities, - game_type, + package_key, &sim_setup.args_program, &sim_setup.game_actions, Some(&|_, cradles| cradles[0].handshake_finished() && cradles[1].handshake_finished()), @@ -2984,10 +2991,14 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { .expect("encode debug parameters"); let params1 = Program::from_nodeptr(&mut allocator, params1_node).expect("debug parameters"); + let debug_type = crate::session_phases::game_collection::game_type_for_package( + &mut allocator, + package_key, + ); let result1 = outcome.cradles[0].propose_games( &mut allocator, &[GameProposal { - game_type: GameType(game_type.to_vec()), + game_type: debug_type.clone(), timeout: Timeout::new(15), parameters: params1, }], @@ -3007,7 +3018,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { let result2 = outcome.cradles[1].propose_games( &mut allocator, &[GameProposal { - game_type: GameType(game_type.to_vec()), + game_type: debug_type, timeout: Timeout::new(15), parameters: params2, }], @@ -3058,7 +3069,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, sim_setup.private_keys.clone(), &sim_setup.identities, - b"debug", + "debug", &sim_setup.args_program, &sim_setup.game_actions, Some(&|_, cradles| cradles[0].channel_status_terminal() && cradles[1].is_abandoned()), @@ -3101,7 +3112,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, sim_setup.private_keys, &sim_setup.identities, - b"debug", + "debug", &sim_setup.args_program, &sim_setup.game_actions, None, @@ -3985,6 +3996,13 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { ); let p1_notifs = &outcome.local_uis[1].notifications; + assert!(p1_notifs.iter().any(|notification| matches!( + notification, + GameNotification::LocalActionApplied { + id: GameID(1), + action: LocalActionKind::Cheat, + } + ))); assert!( p1_notifs .iter() @@ -4203,6 +4221,16 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { notification_coin_in_mempool, "PlayingMove became observable before its spend reached the mempool: {host_events:?}" ); + assert!(host_events[..playing_index].iter().any(|event| matches!( + event, + HostBoundaryEvent::Notification { + notification: GameNotification::LocalActionApplied { + id: GameID(1), + action: LocalActionKind::MakeMove, + }, + .. + } + ))); assert!( host_events[..playing_index].iter().any(|event| { matches!( @@ -4267,6 +4295,27 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { let p0_notifs = &outcome.local_uis[0].notifications; let p1_notifs = &outcome.local_uis[1].notifications; + let applied_index = p0_notifs + .iter() + .position(|notification| matches!( + notification, + GameNotification::LocalActionApplied { + id: GameID(1), + action: LocalActionKind::AcceptSettlement, + } + )) + .expect("on-chain accept should emit LocalActionApplied"); + let terminal_index = p0_notifs + .iter() + .position(|notification| matches!( + notification, + GameNotification::GameSettled { id: GameID(1), .. } + )) + .expect("on-chain accept should eventually settle"); + assert!( + applied_index < terminal_index, + "action-applied must precede its terminal notification: {p0_notifs:?}" + ); assert_reward_coin_consistency(p0_notifs, "accept_finished p0"); assert_reward_coin_consistency(p1_notifs, "accept_finished p1"); assert!( @@ -4393,7 +4442,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, sim_setup.private_keys.clone(), &sim_setup.identities, - b"debug", + "debug", &sim_setup.args_program, &sim_setup.game_actions, None, @@ -4822,7 +4871,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, private_keys, &identities, - b"calpoker", + "calpoker", &Program::from_hex("80").unwrap(), &moves, None, @@ -4892,7 +4941,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, private_keys, &identities, - b"calpoker", + "calpoker", &Program::from_hex("80").unwrap(), &moves, None, @@ -5858,7 +5907,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, sim_setup.private_keys.clone(), &sim_setup.identities, - b"debug", + "debug", &sim_setup.args_program, &sim_setup.game_actions, Some(&|_, cradles| { @@ -5943,7 +5992,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, sim_setup.private_keys.clone(), &sim_setup.identities, - b"debug", + "debug", &sim_setup.args_program, &sim_setup.game_actions, Some(&|_, cradles| { @@ -6041,7 +6090,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, sim_setup.private_keys.clone(), &sim_setup.identities, - b"debug", + "debug", &sim_setup.args_program, &sim_setup.game_actions, Some(&|_, cradles| cradles[0].is_on_chain() || cradles[0].is_failed()), @@ -6132,7 +6181,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, sim_setup.private_keys.clone(), &sim_setup.identities, - b"debug", + "debug", &sim_setup.args_program, &sim_setup.game_actions, Some(&|_, cradles| { @@ -6215,7 +6264,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, sim_setup.private_keys.clone(), &sim_setup.identities, - b"debug", + "debug", &sim_setup.args_program, &sim_setup.game_actions, None, @@ -6274,7 +6323,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, sim_setup.private_keys.clone(), &sim_setup.identities, - b"debug", + "debug", &sim_setup.args_program, &sim_setup.game_actions, None, @@ -6320,7 +6369,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, sim_setup.private_keys.clone(), &sim_setup.identities, - b"debug", + "debug", &sim_setup.args_program, &sim_setup.game_actions, None, @@ -6373,7 +6422,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, sim_setup.private_keys.clone(), &sim_setup.identities, - b"debug", + "debug", &sim_setup.args_program, &sim_setup.game_actions, None, @@ -6665,7 +6714,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { &mut rng, sim_setup.private_keys.clone(), &sim_setup.identities, - b"debug", + "debug", &sim_setup.args_program, &sim_setup.game_actions, None, diff --git a/src/simulator/tests/session_phases_sim/script_runner.rs b/src/simulator/tests/session_phases_sim/script_runner.rs index 2343f36b4..219159fb7 100644 --- a/src/simulator/tests/session_phases_sim/script_runner.rs +++ b/src/simulator/tests/session_phases_sim/script_runner.rs @@ -120,7 +120,7 @@ pub(in super::super) fn run_script( allocator: &mut AllocEncoder, rng: &mut ChaCha8Rng, identities: &[ChiaIdentity], - game_type: &[u8], + package_key: &str, extras: &Program, moves_input: &[SimScriptAction], pred: GameRunEarlySuccessPredicate, @@ -134,6 +134,10 @@ pub(in super::super) fn run_script( let test_name = crate::simulator::current_test_name().unwrap_or_else(|| "unknown".to_string()); let mut ending = None; let mut assertion_scheduler = AssertionScheduler::default(); + let proposal_type = + crate::session_phases::game_collection::game_type_for_package(allocator, package_key); + let krunk_type = + crate::session_phases::game_collection::game_type_for_package(allocator, "krunk"); let has_explicit_go_on_chain = moves_input .iter() @@ -216,17 +220,17 @@ pub(in super::super) fn run_script( SimScriptAction::ProposeNewGameWithTimeout(_, _, timeout) => *timeout, _ => 15, }; - let parameters = if game_type == b"calpoker" { + let parameters = if package_key == "calpoker" { let node = (Amount::new(100), (my_turn, ())) .to_clvm(allocator) .into_gen()?; Program::from_nodeptr(allocator, node)? - } else if game_type == b"spacepoker" { + } else if package_key == "spacepoker" { let node = (Amount::new(100), (extras.clone(), (my_turn, ()))) .to_clvm(allocator) .into_gen()?; Program::from_nodeptr(allocator, node)? - } else if game_type == b"debug" { + } else if package_key == "debug" { let node = ( Amount::new(100), (Amount::new(100), (my_turn, (extras.clone(), ()))), @@ -241,7 +245,7 @@ pub(in super::super) fn run_script( allocator, *who, &[GameProposal { - game_type: GameType(game_type.to_vec()), + game_type: proposal_type.clone(), timeout: Timeout::new(timeout), parameters, }], @@ -253,7 +257,7 @@ pub(in super::super) fn run_script( allocator, *who, &[GameProposal { - game_type: GameType(b"krunk".to_vec()), + game_type: krunk_type.clone(), timeout: Timeout::new(15), parameters: Program::from_hex("64")?, }], @@ -395,12 +399,12 @@ pub(in super::super) fn run_script( () } SimScriptAction::WrongParityProposal(who) => { - let parameters = if game_type == b"calpoker" { + let parameters = if package_key == "calpoker" { let node = (Amount::new(100), (true, ())) .to_clvm(allocator) .into_gen()?; Program::from_nodeptr(allocator, node)? - } else if game_type == b"spacepoker" { + } else if package_key == "spacepoker" { let node = (Amount::new(100), (extras.clone(), (true, ()))) .to_clvm(allocator) .into_gen()?; @@ -412,7 +416,7 @@ pub(in super::super) fn run_script( allocator, *who, &[GameProposal { - game_type: GameType(game_type.to_vec()), + game_type: proposal_type.clone(), timeout: Timeout::new(15), parameters, }], @@ -424,12 +428,12 @@ pub(in super::super) fn run_script( () } SimScriptAction::InvalidProposalParameters(who) => { - let parameters = if game_type == b"calpoker" { + let parameters = if package_key == "calpoker" { let node = (Amount::new(100), (true, ())) .to_clvm(allocator) .into_gen()?; Program::from_nodeptr(allocator, node)? - } else if game_type == b"spacepoker" { + } else if package_key == "spacepoker" { let node = (Amount::new(100), (extras.clone(), (true, ()))) .to_clvm(allocator) .into_gen()?; @@ -441,7 +445,7 @@ pub(in super::super) fn run_script( allocator, *who, &[GameProposal { - game_type: GameType(game_type.to_vec()), + game_type: proposal_type.clone(), timeout: Timeout::new(15), parameters, }], @@ -453,12 +457,12 @@ pub(in super::super) fn run_script( () } SimScriptAction::InvalidProposalTimeout(who) => { - let parameters = if game_type == b"calpoker" { + let parameters = if package_key == "calpoker" { let node = (Amount::new(100), (true, ())) .to_clvm(allocator) .into_gen()?; Program::from_nodeptr(allocator, node)? - } else if game_type == b"spacepoker" { + } else if package_key == "spacepoker" { let node = (Amount::new(100), (extras.clone(), (true, ()))) .to_clvm(allocator) .into_gen()?; @@ -470,7 +474,7 @@ pub(in super::super) fn run_script( allocator, *who, &[GameProposal { - game_type: GameType(game_type.to_vec()), + game_type: proposal_type.clone(), timeout: Timeout::new(15), parameters, }], diff --git a/src/test_support/mod.rs b/src/test_support/mod.rs index 84a88f6e9..7be7bd344 100644 --- a/src/test_support/mod.rs +++ b/src/test_support/mod.rs @@ -1,10 +1,11 @@ +pub mod peer; +pub mod sim_script; + #[cfg(test)] -pub mod calpoker_sim; +pub use crate::games::calpoker::tests::sim as calpoker_sim; #[cfg(test)] -pub mod debug_game; +pub use crate::games::debug as debug_game; #[cfg(test)] -pub mod krunk_sim; -pub mod peer; -pub mod sim_script; +pub use crate::games::krunk::tests::sim as krunk_sim; #[cfg(test)] -pub mod spacepoker_sim; +pub use crate::games::spacepoker::tests::sim as spacepoker_sim; diff --git a/src/test_support/peer/peer_harness.rs b/src/test_support/peer/peer_harness.rs index 17c97e4d8..fe9da5c77 100644 --- a/src/test_support/peer/peer_harness.rs +++ b/src/test_support/peer/peer_harness.rs @@ -7,8 +7,6 @@ use crate::channel_state::types::ChannelEnv; #[cfg(test)] use crate::channel_state::types::{ChannelPrivateKeys, ReadableMove}; use crate::common::standard_coin::private_to_public_key; -#[cfg(test)] -use crate::common::types::GameType; use crate::common::types::{ AllocEncoder, Amount, CoinID, CoinString, Error, IntoErr, PuzzleHash, Spend, SpendBundle, }; @@ -378,13 +376,7 @@ fn get_channel_coin_for_handler(p: &dyn PeerLifecyclePhase) -> Result) -> Option { - if let Some(ih) = peer.as_any_mut().downcast_mut::() { - return ih.take_off_chain_phase(); - } - if let Some(rh) = peer.as_any_mut().downcast_mut::() { - return rh.take_off_chain_phase(); - } - None + peer.take_off_chain_phase_for_testing() } #[cfg(test)] @@ -524,11 +516,7 @@ pub fn test_peer_smoke() { { let start_effect = { let mut env = ChannelEnv::new(&mut allocator).expect("should work"); - let ih = handlers[0] - .as_any_mut() - .downcast_mut::() - .expect("handler[0] should be initiator"); - ih.start(&mut env).expect("should work") + handlers[0].start_handshake(&mut env).expect("should work") }; apply_effects( start_effect.into_iter().collect(), @@ -572,12 +560,13 @@ pub fn test_peer_smoke() { .expect("encode proposal parameters"); let parameters = Program::from_nodeptr(&mut allocator, params_node).expect("proposal parameters"); + let calpoker_type = game_collection::game_type_for_package(&mut allocator, "calpoker"); let mut env = ChannelEnv::new(&mut allocator).expect("should work"); let (game_ids, effects1) = FromLocalUI::propose_games( &mut peers[1], &mut env, &[GameProposal { - game_type: GameType(b"calpoker".to_vec()), + game_type: calpoker_type, timeout: Timeout::new(15), parameters, }], diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 4f9558b35..1f85c13fd 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -1,12 +1,5 @@ -pub mod calpoker_handlers; -pub mod calpoker_validation; pub mod channel_state; pub mod chialisp; pub mod constants; -pub mod dict_tree_lookup; -pub mod krunk_handlers; -pub mod krunk_validation; pub mod referee_conditions; -pub mod spacepoker_handlers; -pub mod spacepoker_validation; pub mod standard_coin; diff --git a/tools/build-chialisp.sh b/tools/build-chialisp.sh index ffa46f37c..66dde3ae5 100755 --- a/tools/build-chialisp.sh +++ b/tools/build-chialisp.sh @@ -10,19 +10,42 @@ STATE_FILE=".build-chialisp.state" CURRENT_STATE=$(mktemp) trap 'rm -f "$CURRENT_STATE"' EXIT +# GNU find errors if a named root is missing. Only search directories that exist. +find_chialisp() { + local dirs=() + [ -d clsp ] && dirs+=(clsp) + [ -d games ] && dirs+=(games) + if [ ${#dirs[@]} -eq 0 ]; then + return 0 + fi + find "${dirs[@]}" "$@" +} + +clsp_sources() { + { + find_chialisp -type f \( -name '*.clsp' -o -name '*.clinc' \) -print + for file in \ + build.rs Cargo.toml Cargo.lock chialisp.toml \ + games/registry.json \ + tools/build-chialisp.sh + do + [ -f "$file" ] && printf '%s\n' "$file" + done + } | LC_ALL=C sort +} + +clsp_hex() { + find_chialisp -type f -name '*.hex' -print | LC_ALL=C sort +} + write_state() { local destination=$1 { echo "version 1" - { - find clsp -type f \( -name '*.clsp' -o -name '*.clinc' \) -print - printf '%s\n' \ - build.rs Cargo.toml Cargo.lock chialisp.toml \ - tools/build-chialisp.sh - } | LC_ALL=C sort | while IFS= read -r file; do + clsp_sources | while IFS= read -r file; do printf 'input %s %s\n' "$(git hash-object "$file")" "$file" done - find clsp -type f -name '*.hex' -print | LC_ALL=C sort | while IFS= read -r file; do + clsp_hex | while IFS= read -r file; do printf 'output %s %s\n' "$(git hash-object "$file")" "$file" done } > "$destination" @@ -39,16 +62,14 @@ elif [ -f "$STATE_FILE" ] && cmp -s "$CURRENT_STATE" "$STATE_FILE"; then fi SECONDS=0 -find clsp -name '*.hex' -delete +find_chialisp -name '*.hex' -delete # CHIALISP_COMPILE is deliberately unique. Cargo tracks it as a build-script # input, so this forces one Chialisp compile without deleting Cargo's package # cache. Ordinary cargo commands leave it unset and never compile Chialisp. CHIALISP_COMPILE="$(date +%s)-$$-${RANDOM:-0}" cargo build --features sim-server -# Prefer head -n 1 over find's early-exit primary: that primary is GNU-only -# and is rejected by macOS BSD find. -if ! find clsp -type f -name '*.hex' -print | head -n 1 | grep -q .; then +if ! { find_chialisp -type f -name '*.hex' -print | head -n 1 | grep -q .; }; then echo "Error: Chialisp build produced no .hex files" >&2 exit 1 fi diff --git a/tools/compile-krunk-only.sh b/tools/compile-krunk-only.sh index 3798317a9..044725a03 100755 --- a/tools/compile-krunk-only.sh +++ b/tools/compile-krunk-only.sh @@ -58,15 +58,15 @@ echo "Using build-script: $BUILD_SCRIPT" echo "=== Compiling Krunk chialisp only (build-script, sequential) ===" # helpers already compiled if hex present; recompile only if missing -if [[ ! -f clsp/games/krunk/krunk_helpers_list_contains.hex ]]; then - compile_one krunk-helpers "clsp/games/krunk/krunk_helpers.clsp" +if [[ ! -f games/krunk/clsp/krunk_helpers_list_contains.hex ]]; then + compile_one krunk-helpers "games/krunk/clsp/krunk_helpers.clsp" else echo "=== Skipping krunk-helpers (hex present) ===" fi -compile_one krunk-validator-commit "clsp/games/krunk/onchain/commit.clsp" -compile_one krunk-validator-guess "clsp/games/krunk/onchain/guess.clsp" -compile_one krunk-validator-clue "clsp/games/krunk/onchain/clue.clsp" -compile_one krunk-generate "clsp/games/krunk/krunk_include.clsp" +compile_one krunk-validator-commit "games/krunk/clsp/onchain/commit.clsp" +compile_one krunk-validator-guess "games/krunk/clsp/onchain/guess.clsp" +compile_one krunk-validator-clue "games/krunk/clsp/onchain/clue.clsp" +compile_one krunk-generate "games/krunk/clsp/factory.clsp" echo "=== Krunk chialisp compile done ===" diff --git a/tools/stage-production.sh b/tools/stage-production.sh index 6529bc726..044b68556 100755 --- a/tools/stage-production.sh +++ b/tools/stage-production.sh @@ -51,8 +51,8 @@ while IFS= read -r -d '' f; do echo "=== Sanity-checking Krunk files ===" for f in \ - "clsp/games/krunk/krunk_include_krunk_factory.hex" \ - "clsp/games/krunk/krunk_signed_dict_tree.dat" + "games/krunk/clsp/factory_krunk_factory.hex" \ + "games/krunk/clsp/krunk_signed_dict_tree.dat" do if [ ! -f "$PLAYER_STAGE/$f" ]; then echo "ERROR: missing $f in player staging" diff --git a/tools/test-build-chialisp.sh b/tools/test-build-chialisp.sh index 8cced1a91..b9cd4b1c2 100755 --- a/tools/test-build-chialisp.sh +++ b/tools/test-build-chialisp.sh @@ -8,8 +8,9 @@ trap 'rm -rf "$TEST_ROOT"' EXIT REPO="$TEST_ROOT/repo" FAKE_BIN="$TEST_ROOT/bin" LOG="$TEST_ROOT/cargo.log" -mkdir -p "$REPO/tools" "$REPO/clsp" "$FAKE_BIN" +mkdir -p "$REPO/tools" "$REPO/clsp" "$REPO/games" "$FAKE_BIN" cp "$SCRIPT_DIR/build-chialisp.sh" "$REPO/tools/build-chialisp.sh" +printf '%s\n' '{}' > "$REPO/games/registry.json" # Reject GNU-only find early-exit usage (unsupported on macOS BSD find). if grep -E '(^|[[:space:]])-quit([[:space:]]|$)' "$REPO/tools/build-chialisp.sh" >/dev/null; then echo "build-chialisp.sh must not use find's GNU-only early-exit primary" >&2 diff --git a/tools/verify-deploy-archives.mjs b/tools/verify-deploy-archives.mjs index 0cd7e73ef..b77744e55 100644 --- a/tools/verify-deploy-archives.mjs +++ b/tools/verify-deploy-archives.mjs @@ -140,6 +140,9 @@ function floorCheckPlayer(stageDir) { if (!dirHasHexFiles(join(nonceDir, "clsp"))) { errors.push("clsp/ is missing or has no .hex files"); } + if (!dirHasHexFiles(join(nonceDir, "games"))) { + errors.push("games/ is missing or has no factory .hex files"); + } if (!dirIsNonempty(join(nonceDir, "images"))) { errors.push("images/ is missing or empty"); } diff --git a/wasm/contract.d.ts b/wasm/contract.d.ts new file mode 100644 index 000000000..8c18cca38 --- /dev/null +++ b/wasm/contract.d.ts @@ -0,0 +1,253 @@ +export interface Amount { + amt: bigint; +} + +export interface Spend { + puzzle: string; + solution: string; + signature: string; +} + +export interface CoinSpend { + coin: string; + bundle: Spend; +} + +export interface SpendBundle { + name?: string; + spends: CoinSpend[]; +} + +export interface IChiaIdentity { + private_key: string; + synthetic_private_key: string; + public_key: string; + synthetic_public_key: string; + puzzle: string; + puzzle_hash: string; +} + +export interface NeedCoinSpendRequest { + amount: string; + conditions: Array<{ opcode: bigint | number; args: string[] }>; + coin_id?: string; + max_height?: bigint | number; +} + +export type ChannelStatus = + | 'Handshaking' + | 'WaitingForHeightToOffer' + | 'WaitingForHeightToAccept' + | 'OurWalletMakingOffer' + | 'OurWalletMakingOfferAcceptance' + | 'OfferSent' + | 'TransactionPending' + | 'Active' + | 'ShuttingDown' + | 'ShutdownTransactionPending' + | 'GoingOnChain' + | 'Unrolling' + | 'ResolvedClean' + | 'ResolvedUnrolled' + | 'ResolvedStale' + | 'Failed'; + +export type SessionDisposition = 'AwaitOutboundTerminal' | 'Abandoned'; + +export type ChannelSemanticPhase = + | 'submitting_channel_spend' + | 'unrolling' + | 'finding_state' + | 'preempting' + | 'finishing_waiting_timeout' + | 'finishing_spending' + | 'resolving'; + +export interface ChannelStatusPayload { + state: ChannelStatus; + session_disposition?: SessionDisposition | null; + advisory: string | null; + coin: unknown; + our_balance: unknown; + their_balance: unknown; + game_allocated: unknown; + have_potato?: boolean | null; + zero_payout?: boolean | null; + unroll_initiator?: 'us' | 'opponent' | null; + semantic_phase?: ChannelSemanticPhase | null; + state_number?: bigint | null; + unrolling_state_number?: bigint | null; + preempting_state_number?: bigint | null; +} + +export type GameStatusState = + | 'my-turn' + | 'their-turn' + | 'on-chain-my-turn' + | 'on-chain-their-turn' + | 'replaying' + | 'playing-move' + | 'illegal-move-detected' + | 'finishing-waiting-timeout' + | 'finishing-spending' + | 'ended-cancelled' + | 'ended-error'; + +export interface GameStatusOtherParams { + readable?: unknown; + mover_share?: unknown; + illegal_move_detected?: boolean; + moved_by_us?: boolean; + game_finished?: boolean; + forfeited?: boolean; + submitting_timeout_claim?: boolean; +} + +export interface GameStatusPayload { + id: bigint; + status: GameStatusState; + my_reward: unknown | null; + coin_id: unknown | null; + reason: string | null; + other_params: GameStatusOtherParams | null; +} + +export type SettlementOutcome = + | 'accept_settlement' + | 'settled_cleanly' + | 'opponent_timed_out' + | 'forfeited_skipped_reveal' + | 'lost' + | 'forfeited_we_accepted' + | 'we_accepted' + | 'attempt_to_move_failed' + | 'timed_out_waiting_for_our_move' + | 'slashed_opponent' + | 'opponent_slashed_us' + | 'opponent_cheated'; + +export interface GameSettledPayload { + id: bigint; + outcome: SettlementOutcome; + our_share: unknown; + coin_id: unknown | null; +} + +export interface ProposalMadePayload { + id: bigint; + group_ids: bigint[]; + my_contribution: unknown; + their_contribution: unknown; + timeout: unknown; + initial_validation_program_hash: unknown; + initial_state: unknown; + game_type: unknown; + parameters: unknown; +} + +export interface ProposalAcceptedPayload { + id: bigint; + amount: unknown; + our_turn: boolean; +} + +export type CancelReason = + | 'SupersededByIncoming' + | 'PeerProposalPending' + | 'GameActive' + | 'CancelledByPeer' + | 'CancelledByUs' + | 'ChannelError' + | 'WentOnChain' + | 'CleanShutdown'; + +export interface ProposalCancelledPayload { + id: bigint; + reason: CancelReason; +} + +export interface InsufficientBalancePayload { + id: bigint; + our_balance_short: boolean; + their_balance_short: boolean; +} + +export interface ActionFailedPayload { + id?: bigint; + action?: 'make_move' | 'accept_settlement' | 'cheat'; + reason: string; +} + +export interface MoveRejectedPayload { + id: bigint; + tag: string; + message: string; +} + +export interface LocalActionAppliedPayload { + id: bigint; + action: 'make_move' | 'accept_settlement' | 'cheat'; +} + +export interface WasmNotificationMap { + ChannelStatus: ChannelStatusPayload; + GameStatus: GameStatusPayload; + GameSettled: GameSettledPayload; + ProposalMade: ProposalMadePayload; + ProposalAccepted: ProposalAcceptedPayload; + ProposalCancelled: ProposalCancelledPayload; + InsufficientBalance: InsufficientBalancePayload; + MoveRejected: MoveRejectedPayload; + ActionFailed: ActionFailedPayload; + LocalActionApplied: LocalActionAppliedPayload; +} + +export type WasmNotification = { + [K in keyof WasmNotificationMap]: { [P in K]: WasmNotificationMap[P] } & { + [P in Exclude]?: never; + }; +}[keyof WasmNotificationMap]; + +export type GameSessionEvent = + | { OutboundMessage: Uint8Array } + | { Notification: WasmNotification } + | { Log: string } + | { CoinSolutionRequest: string } + | { ReceiveError: string } + | { NeedCoinSpend: NeedCoinSpendRequest } + | { NeedLauncherCoin: true }; + +export interface WatchedCoinEntry { + coin_name: string; + coin_string: string; +} + +export type WasmDisposition = + | { kind: 'active' } + | { kind: 'await-outbound-terminal'; command: { id: string; message: Uint8Array } } + | { kind: 'terminal' }; + +export interface WasmResult { + events: GameSessionEvent[]; + watchCoins: WatchedCoinEntry[]; + unwatchCoins: WatchedCoinEntry[]; + actionSucceeded: boolean; + disposition: WasmDisposition; + ids?: string[]; +} + +export interface GameSessionConfig { + rng_id: number; + have_potato: boolean; + my_contribution: Amount; + their_contribution: Amount; + channel_timeout: number; + unroll_timeout: number; + reward_puzzle_hash: string; + genesis_challenge: string; +} + +export interface GameSessionCreateResult { + id: number; + puzzle_hash: string; +} diff --git a/wasm/src/mod.rs b/wasm/src/mod.rs index be6db7f0a..d00f772b0 100644 --- a/wasm/src/mod.rs +++ b/wasm/src/mod.rs @@ -1,4 +1,3 @@ -#[allow(unused_variables)] // enable this so 'typescript_type' can be named 'typescript_type' mod gaming_wasm { use std::cell::RefCell; @@ -52,75 +51,7 @@ mod gaming_wasm { LockedAllocator::new(FreeListAllocator::new()); #[wasm_bindgen(typescript_custom_section)] - const TS_APPEND_CONTENT: &'static str = r#" - export type Amount = { - "amt": number, - }; - - export type Spend = { - "puzzle": string, - "solution": string, - "signature": string - }; - - export type CoinSpend = { - "coin": string, - "bundle": Spend - }; - - export type SpendBundle = { - "name"?: string, - "spends": Array - }; - - export type IChiaIdentity = { - "private_key": string, - "synthetic_private_key": string, - "public_key": string, - "synthetic_public_key": string, - "puzzle": string, - "puzzle_hash": string, - }; - - export type NeedCoinSpendRequest = { - "amount": string, - "conditions": Array<{ "opcode": bigint | number, "args": Array }>, - "coin_id"?: string, - "max_height"?: bigint | number, - }; - - export type GameSessionEvent = - | { OutboundMessage: string } - | { OutboundTransaction: SpendBundle } - | { Notification: any } - | { Log: string } - | { CoinSolutionRequest: string } - | { ReceiveError: string } - | { NeedCoinSpend: NeedCoinSpendRequest } - | { NeedLauncherCoin: boolean }; - - export type DrainResult = { - "events": Array, - "watchCoins": Array<{ coin_name: string, coin_string: string }>, - "unwatchCoins": Array<{ coin_name: string, coin_string: string }>, - }; - - export type GameSessionConfig = { - "seed": string | undefined, - "have_potato": boolean, - "my_contribution": Amount, - "their_contribution": Amount, - "channel_timeout": number, - "reward_puzzle_hash": string - }; - - export type GameSessionResult = { - "id": number, - "puzzle_hash": string, - }; - - export type IChiaIdentityFun = (seed: string) => IChiaIdentity; - "#; + const TS_APPEND_CONTENT: &'static str = include_str!("../contract.d.ts"); #[derive(Serialize, Deserialize, Default, Debug)] struct JsAmount { @@ -138,7 +69,7 @@ mod gaming_wasm { /// Increment for every incompatible change to the persisted `JsGameSession` /// shape, including incompatible shapes owned by nested Rust types. - const GAME_SESSION_SERIALIZATION_SCHEMA: u32 = 5; + const GAME_SESSION_SERIALIZATION_SCHEMA: u32 = 6; #[derive(Serialize)] struct JsWatchCoinEntry { @@ -221,8 +152,10 @@ mod gaming_wasm { fn parse_game_config(js_config: JsValue) -> Result { let jsconfig: JsGameSessionConfig = serde_wasm_bindgen::from_value(js_config).into_js()?; - let mut allocator = AllocEncoder::new(); - let game_types = game_collection(&mut allocator); + // Handshake does not need factories. Page load warms them in the + // background; OffChainPhase installs the cached collection when the + // channel becomes live. + let game_types = BTreeMap::new(); let reward_puzzle_hash_bytes = hex::decode(&jsconfig.reward_puzzle_hash).map_err(|e| { js_error(&format!( "reward_puzzle_hash hex decode: {e:?} (length={})", @@ -288,12 +221,6 @@ mod gaming_wasm { } } - #[wasm_bindgen] - extern "C" { - #[wasm_bindgen(typescript_type = "ICreateGameSession")] - pub type ICreateGameSession; - } - #[wasm_bindgen] pub fn create_rng(seed: String) -> Result { let hashed = Sha256Input::Bytes(seed.as_bytes()).hash(); @@ -319,9 +246,10 @@ mod gaming_wasm { }) } - /// The name 'typescript_type' is part of the FFI - #[wasm_bindgen(typescript_type = "ICreateGameSession")] - pub fn create_game_session(js_config: JsValue) -> Result { + #[wasm_bindgen(unchecked_return_type = "GameSessionCreateResult")] + pub fn create_game_session( + #[wasm_bindgen(unchecked_param_type = "GameSessionConfig")] js_config: JsValue, + ) -> Result { let new_id = get_next_id(); let partial = parse_game_config(js_config)?; with_rng(partial.rng_id, move |rng: &mut ChaCha8Rng| { @@ -425,15 +353,8 @@ mod gaming_wasm { .collect() } - #[wasm_bindgen] - pub fn get_watching_coins(cid: i32) -> Result { - let result = with_game(cid, move |cradle: &mut JsGameSession| Ok(watch_coin_entries(cradle)))?; - serde_wasm_bindgen::to_value(&result).map_err(|e| JsValue::from_str(&e.to_string())) - } - /// Durable watched-coin snapshot for seeding host polling after attach or - /// restore. Same shape as [`get_watching_coins`]; both delegate to the - /// manager. + /// restore. #[wasm_bindgen] pub fn snapshot_watched_coins(cid: i32) -> Result { let result = with_game(cid, move |cradle: &mut JsGameSession| Ok(watch_coin_entries(cradle)))?; @@ -805,7 +726,7 @@ mod gaming_wasm { #[derive(Deserialize)] struct JsGameProposal { - // Game name + // First generated member's initial validation puzzle hash, as 32-byte hex. game_type: String, timeout: u64, } @@ -820,6 +741,54 @@ mod gaming_wasm { })?)) } + fn parse_game_type_hex(hex_id: &str) -> Result { + let trimmed = hex_id.strip_prefix("0x").unwrap_or(hex_id); + let bytes = hex::decode(trimmed).map_err(|e| { + JsValue::from_str(&format!("game_type must be hex of a 32-byte hash: {e}")) + })?; + let hash = Hash::from_slice(&bytes).map_err(|e| { + JsValue::from_str(&format!("game_type must be a 32-byte hash: {e}")) + })?; + Ok(GameType::from_hash(hash)) + } + + #[derive(Serialize)] + struct JsPackageIdentity { + key: String, + id: String, + } + + /// Bootstrap metadata: catalog `key` plus first-member validation puzzle hash `id`. + /// Registration discovers `id` by running the factory with representative parameters. + /// Peer/WASM wire uses `id` (the hash). The JS session model and saves use catalog keys. + #[wasm_bindgen] + pub fn registered_game_packages() -> Result { + let mut allocator = AllocEncoder::new(); + let ids = game_collection::production_package_ids(&mut allocator); + let list: Vec = ids + .into_iter() + .map(|(key, id)| JsPackageIdentity { + key, + id: id.to_string(), + }) + .collect(); + serde_wasm_bindgen::to_value(&list).map_err(|e| JsValue::from_str(&format!("{e}"))) + } + + /// Probe one production factory into the process-wide cache. Idempotent. + /// The host yields between calls so the browser event loop can stay responsive. + #[wasm_bindgen] + pub fn warm_game_package(key: String) -> Result { + let mut allocator = AllocEncoder::new(); + let id = game_collection::warm_production_package(&mut allocator, &key) + .map_err(|e| JsValue::from_str(&e))?; + serde_wasm_bindgen::to_value(&JsPackageIdentity { + key, + id: id.to_string(), + }) + .map_err(|e| JsValue::from_str(&format!("{e}"))) + } + #[wasm_bindgen] pub fn propose_games(cid: i32, games: JsValue, parameters_list: JsValue) -> Result { let js_games: Vec = @@ -830,15 +799,16 @@ mod gaming_wasm { return Err(JsValue::from_str("games and parameters_list must have the same length")); } with_game(cid, move |cradle: &mut JsGameSession| { - let game_starts: Vec = js_games - .iter() - .zip(params_arr.iter()) - .map(|(g, p)| GameProposal { - game_type: GameType(g.game_type.as_bytes().to_vec()), + let mut game_starts = Vec::with_capacity(js_games.len()); + for (g, p) in js_games.iter().zip(params_arr.iter()) { + let game_type = parse_game_type_hex(&g.game_type) + .map_err(|e| types::Error::StrErr(format!("{e:?}")))?; + game_starts.push(GameProposal { + game_type, timeout: Timeout::new(g.timeout), parameters: Program::from_bytes(p), - }) - .collect(); + }); + } let ids = cradle.cradle.propose_games( &mut cradle.allocator, &game_starts, @@ -944,11 +914,16 @@ mod gaming_wasm { .parse::() .map_err(|e| JsValue::from_str(&e.to_string()))?, ); - with_game_drain(cid, move |cradle: &mut JsGameSession| { - cradle - .cradle - .cheat(&mut cradle.allocator, &game_id, share) - }) + with_game_action_drain( + cid, + game_id.clone(), + FailedGameAction::Cheat, + move |cradle: &mut JsGameSession| { + cradle + .cradle + .cheat(&mut cradle.allocator, &game_id, share) + }, + ) } #[wasm_bindgen] @@ -1024,30 +999,6 @@ mod gaming_wasm { .into_js() } - #[wasm_bindgen] - #[deprecated(note = "Game state should come from notifications in the DrainResult")] - #[allow(deprecated)] - pub fn get_game_state_id(cid: i32) -> Result, JsValue> { - with_game(cid, move |cradle: &mut JsGameSession| { - Ok(cradle - .cradle - .get_game_state_id(&mut cradle.allocator)? - .map(|h| hex::encode(h.bytes()))) - }) - } - - #[wasm_bindgen] - #[deprecated(note = "Duplicate of game_session_amount; balance should come from notifications")] - #[allow(deprecated)] - pub fn get_amount(cid: i32) -> Result { - serde_wasm_bindgen::to_value(&with_game(cid, move |cradle: &mut JsGameSession| { - Ok(JsAmount { - amt: cradle.cradle.amount(), - }) - })?) - .into_js() - } - #[wasm_bindgen] pub fn accept_settlement(cid: i32, id: &str) -> Result { let game_id = string_to_game_id(id)?; @@ -1279,9 +1230,10 @@ mod gaming_wasm { "OutboundTerminalMessage should be intercepted before JS event serialization" .to_string(), )), - GameSessionEvent::OutboundTransaction(bundle, _expiry) => { - json_event_to_js(serde_json::json!({ "OutboundTransaction": spend_bundle_to_js(bundle) })) - } + GameSessionEvent::OutboundTransaction(_, _) => Err(types::Error::StrErr( + "OutboundTransaction should be intercepted before JS event serialization" + .to_string(), + )), GameSessionEvent::Notification(n) => notification_event_to_js(n), GameSessionEvent::Log(line) => { json_event_to_js(serde_json::json!({ "Log": line })) @@ -1304,6 +1256,25 @@ mod gaming_wasm { } } + #[cfg(test)] + mod drain_event_tests { + use super::*; + + #[test] + fn leaked_outbound_transaction_fails_before_js_serialization() { + let event = GameSessionEvent::OutboundTransaction( + SpendBundle { + name: None, + spends: Vec::new(), + }, + None, + ); + + let err = game_session_event_to_js(&event).expect_err("transaction must not leak"); + assert!(format!("{err:?}").contains("OutboundTransaction should be intercepted")); + } + } + /// Build the JS-facing event array for a drained [`ManagerDrain`]. /// /// The [`TransactionManager`] intercepts `OutboundTransaction` and @@ -1417,32 +1388,6 @@ mod gaming_wasm { }) } - #[wasm_bindgen] - pub fn game_session_amount(cid: i32) -> Result { - let amount = with_game(cid, move |cradle: &mut JsGameSession| Ok(cradle.cradle.amount()))?; - serde_wasm_bindgen::to_value(&JsAmount { amt: amount }).into_js() - } - - #[wasm_bindgen] - #[deprecated(note = "Share information should come from game notifications")] - #[allow(deprecated)] - pub fn game_session_our_share(cid: i32) -> Result { - let amount = with_game(cid, move |cradle: &mut JsGameSession| { - Ok(cradle.cradle.get_our_current_share()) - })?; - serde_wasm_bindgen::to_value(&amount.map(|a| JsAmount { amt: a })).into_js() - } - - #[wasm_bindgen] - #[deprecated(note = "Share information should come from game notifications")] - #[allow(deprecated)] - pub fn game_session_their_share(cid: i32) -> Result { - let amount = with_game(cid, move |cradle: &mut JsGameSession| { - Ok(cradle.cradle.get_their_current_share()) - })?; - serde_wasm_bindgen::to_value(&amount.map(|a| JsAmount { amt: a })).into_js() - } - #[derive(Serialize, Deserialize)] struct JsChiaIdentity { pub private_key: String, @@ -1512,7 +1457,7 @@ mod gaming_wasm { Ok(hex::encode(puzzle_hash.bytes())) } - #[wasm_bindgen(typescript_type = "IChiaIdentityFun")] + #[wasm_bindgen(unchecked_return_type = "IChiaIdentity")] pub fn chia_identity(rng_id: i32) -> Result { with_rng(rng_id, move |rng: &mut ChaCha8Rng| { let mut allocator = AllocEncoder::new();