From 977643dc92c68808e16a1d6b46cda9b2129690f1 Mon Sep 17 00:00:00 2001 From: Bram Cohen Date: Wed, 19 Aug 2026 21:18:48 -0700 Subject: [PATCH 01/12] Encapsulate each game under games/ with catalog identities in the session model. Factory hashes stay on the WASM propose/notify boundary, and lastTerms is null until a hand is agreed so saves round-trip atomically. --- .cursor/rules/alpha-no-compat.mdc | 18 + Cargo.toml | 3 +- FRONTEND_ARCHITECTURE.md | 79 ++- GAME_LIFECYCLE.md | 17 +- HANDLER_GUIDE.md | 21 +- OVERVIEW.md | 66 ++- build.rs | 190 ++++++- chialisp.toml | 17 - clsp/games/calpoker/calpoker_include.clsp | 5 - clsp/games/calpoker/game_codes.clinc | 6 - clsp/games/krunk/krunk_include.clsp | 5 - clsp/games/spacepoker/spacepoker_include.clsp | 5 - clsp/handler_api.md | 2 +- clsp/test/test_dict_lookup.clsp | 2 +- clsp/test/test_handcalc_micro.clsp | 8 +- clsp/test/test_make_cards.clsp | 2 +- clsp/test/test_mergein.clsp | 2 +- clsp/test/test_space_hand_eval.clsp | 4 +- clsp/test/unused/test_handcalc.clsp | 2 +- clsp/test/unused/test_onehandcalc.clsp | 2 +- eslint.config.mjs | 12 +- front-end/package.json | 14 +- front-end/rebuild-fe.sh | 16 +- front-end/scripts/assemble-bundle.mjs | 21 + front-end/scripts/generate-game-registry.mjs | 80 +++ front-end/src/App.tsx | 17 +- .../components/FinishedSessionGameView.tsx | 2 +- .../src/components/GameProposalDialogs.tsx | 113 +--- front-end/src/components/GameSession.tsx | 2 +- front-end/src/features/spacePoker/unitSize.ts | 50 -- front-end/src/generated/gamePackages.ts | 9 + front-end/src/generated/gamePresets.ts | 14 + front-end/src/generated/gameStyles.css | 2 + front-end/src/hooks/SessionController.ts | 55 +- front-end/src/hooks/WasmStateInit.ts | 26 +- front-end/src/hooks/useCheatNerfKeys.ts | 49 -- front-end/src/hooks/useGameSession.ts | 30 +- front-end/src/index.css | 55 +- front-end/src/index.tsx | 2 + front-end/src/lib/gameAdapter.ts | 102 ---- front-end/src/lib/gameIdentities.ts | 125 +++++ front-end/src/lib/gameMount.ts | 74 --- front-end/src/lib/gameMountRegistry.tsx | 51 +- front-end/src/lib/gameRegistry.ts | 209 ++++--- front-end/src/lib/session/composeDraft.ts | 94 ++-- .../src/lib/session/gameSessionEvents.ts | 44 +- front-end/src/lib/session/gameSlice.ts | 3 +- front-end/src/lib/session/gameStateCodec.ts | 44 +- front-end/src/lib/session/normalization.ts | 17 +- front-end/src/lib/session/persistence.ts | 70 +-- .../lib/session/persistenceBetweenHands.ts | 106 ++-- front-end/src/lib/session/presentation.ts | 9 +- front-end/src/lib/session/saveEnvelope.ts | 13 +- front-end/src/lib/session/selectors.ts | 2 +- front-end/src/lib/session/sessionMachine.ts | 3 +- .../lib/session/sessionMachineBetweenHands.ts | 32 +- .../src/lib/session/sessionMachineCommands.ts | 12 + .../src/lib/session/sessionMachineGame.ts | 13 +- .../lib/session/sessionMachineInterpreter.ts | 3 +- .../src/lib/session/sessionMachineTypes.ts | 20 +- front-end/src/lib/session/sessionResult.ts | 16 +- front-end/src/lib/session/sessionSnapshot.ts | 61 +- front-end/src/lib/session/types.ts | 64 +-- front-end/src/lib/settlement.ts | 189 +------ front-end/src/lib/tests/compose_draft.test.ts | 42 +- .../src/lib/tests/deployFreshness.test.ts | 6 +- .../tests/finished_session_game_view.test.ts | 2 +- front-end/src/lib/tests/game_adapters.test.ts | 166 +++++- .../lib/tests/game_feature_reducers.test.ts | 37 +- .../src/lib/tests/game_mount_registry.test.ts | 63 ++- .../lib/tests/game_package_isolation.test.ts | 54 ++ front-end/src/lib/tests/game_slice.test.ts | 2 +- .../src/lib/tests/game_state_codecs.test.ts | 8 +- .../load_wasm.calpoker_completion.test.ts | 6 +- .../lib/tests/load_wasm.game_restore.test.ts | 5 +- front-end/src/lib/tests/load_wasm.harness.ts | 10 +- .../tests/load_wasm.krunk_completion.test.ts | 4 +- .../tests/message_protocol.durability.test.ts | 5 + .../src/lib/tests/message_protocol.harness.ts | 15 +- .../tests/message_protocol.transport.test.ts | 132 ++++- front-end/src/lib/tests/protocolIdentities.ts | 25 + .../lib/tests/session_machine.compose.test.ts | 22 +- .../session_machine.feature_state.test.ts | 6 +- .../lib/tests/session_machine.krunk.test.ts | 2 +- .../tests/session_machine.proposals.test.ts | 60 ++ .../tests/session_machine_interpreter.test.ts | 23 +- .../lib/tests/session_model.proposals.test.ts | 2 +- .../lib/tests/session_model.restore.test.ts | 8 +- .../lib/tests/session_model_roundtrip.test.ts | 4 +- .../lib/tests/session_render_boundary.test.ts | 2 +- .../session_save_envelope.boundary.test.ts | 12 +- .../tests/session_save_envelope.fixtures.ts | 10 +- .../session_save_envelope.roundtrip.test.ts | 103 +++- .../session_save_envelope.validation.test.ts | 26 +- .../lib/tests/spacepoker_settlement.test.ts | 5 +- .../lib/tests/terminal_finalization.test.ts | 10 +- .../lib/tests/terminal_game_controls.test.tsx | 20 +- .../src/lib/tests/wasm_state_init.test.ts | 35 +- front-end/src/types/ChiaGaming.ts | 9 +- front-end/src/util.ts | 24 +- front-end/tsconfig.json | 34 +- .../calpoker/clsp}/calpoker_generate.clinc | 18 +- games/calpoker/clsp/factory.clsp | 5 + .../calpoker/clsp}/handcalc.clinc | 2 +- .../calpoker/clsp}/onchain/a.clsp | 2 +- .../clsp}/onchain/arrange_cards.clinc | 0 .../calpoker/clsp}/onchain/b.clsp | 2 +- .../calpoker/clsp}/onchain/c.clsp | 4 +- .../calpoker/clsp}/onchain/d.clsp | 4 +- .../calpoker/clsp}/onchain/e.clsp | 4 +- .../calpoker/clsp}/onchain/flatten_card.clinc | 0 .../calpoker/clsp}/onchain/make_card.clinc | 0 .../calpoker/clsp}/onchain/make_cards.clinc | 0 .../calpoker/clsp}/onchain/onehandcalc.clinc | 2 +- .../calpoker/clsp}/onchain/test_atomsort.clsp | 0 games/calpoker/rust/mod.rs | 23 + .../calpoker/rust/tests/handlers.rs | 4 +- games/calpoker/rust/tests/mod.rs | 11 + .../calpoker/rust/tests/sim.rs | 0 .../calpoker/rust/tests/validation.rs | 6 +- games/calpoker/ui/ComposeEditor.tsx | 29 + .../calpoker/ui}/LiveMount.tsx | 42 +- .../calPoker => games/calpoker/ui}/adapter.ts | 67 ++- .../calpoker/ui}/calPoker.test.ts | 38 +- .../ui}/components/CaliforniaPoker.tsx | 7 +- .../ui}/components/components/Card.tsx | 3 +- .../ui}/components/components/CardContent.tsx | 0 .../components/components/GameBottomBar.tsx | 11 +- .../ui}/components/components/HandDisplay.tsx | 4 +- .../ui}/components/components/MovingCard.tsx | 3 +- .../ui}/components/components/index.ts | 0 .../ui}/components/constants/constants.ts | 0 .../calpoker/ui}/components/index.ts | 0 .../components/utils/FormatHandDescription.ts | 0 .../ui}/components/utils/MakeDescription.ts | 0 .../calpoker/ui}/components/utils/gameLog.ts | 0 .../calpoker/ui}/components/utils/index.ts | 0 .../calPoker => games/calpoker/ui}/index.tsx | 6 +- .../calPoker => games/calpoker/ui}/outcome.ts | 0 games/calpoker/ui/package.ts | 17 + games/calpoker/ui/settlement.ts | 44 ++ .../calpoker/ui}/stateCodec.ts | 2 +- games/calpoker/ui/styles.css | 52 ++ .../calpoker/ui}/types/BestHandType.ts | 0 .../ui}/types/CaliforniapokerProps.ts | 6 +- .../calpoker/ui}/types/CardContentProps.ts | 0 .../calpoker/ui}/types/CardRenderProps.ts | 0 .../calpoker/ui}/types/CardValueSuit.ts | 0 .../calpoker/ui}/types/FormatHandProps.ts | 0 .../calpoker/ui}/types/HandDisplayProps.ts | 0 .../calpoker/ui}/types/MovingCardProps.ts | 0 .../calpoker/ui}/types/cardHelpers.ts | 0 .../calpoker/ui}/types/index.ts | 0 .../calpoker/ui}/useCalpokerHand.ts | 18 +- .../debug/clsp/factory.clsp | 20 +- .../debug_game.rs => games/debug/rust/mod.rs | 46 +- games/debug/rust/tests/mod.rs | 3 + games/host/index.ts | 526 ++++++++++++++++++ .../AmountInput.tsx => games/host/ui.tsx | 105 +++- games/krunk/clsp/factory.clsp | 5 + .../krunk/clsp}/krunk_dict_tree.clinc | 0 .../krunk/clsp}/krunk_generate.clinc | 12 +- .../krunk/clsp}/krunk_helpers.clinc | 0 .../krunk/clsp}/krunk_signed_dict_tree.dat | Bin .../krunk => games/krunk/clsp}/krunkwords.txt | 0 .../krunk/clsp}/onchain/clue.clsp | 6 +- .../krunk/clsp}/onchain/commit.clsp | 4 +- .../krunk/clsp}/onchain/guess.clsp | 0 .../krunk/clsp}/onchain/krunk_make_clue.clinc | 0 .../onchain/krunk_validator_hashes.clinc | 0 .../krunk/rust/bin_gen_krunk_dict.rs | 2 +- .../krunk/rust/dict_tree.rs | 0 games/krunk/rust/mod.rs | 45 ++ .../krunk/rust}/tests/dict_tree_lookup.rs | 0 .../krunk/rust/tests/handlers.rs | 8 +- games/krunk/rust/tests/mod.rs | 13 + .../krunk/rust/tests/sim.rs | 0 .../krunk/rust/tests/validation.rs | 44 +- games/krunk/ui/ComposeEditor.tsx | 38 ++ .../krunk => games/krunk/ui}/Krunk.tsx | 26 +- .../krunk => games/krunk/ui}/LiveMount.tsx | 24 +- .../krunk => games/krunk/ui}/adapter.ts | 46 +- games/krunk/ui/index.ts | 2 + .../krunk => games/krunk/ui}/krunk.test.ts | 18 +- games/krunk/ui/package.ts | 17 + games/krunk/ui/settlement.ts | 27 + .../krunk => games/krunk/ui}/stateCodec.ts | 2 +- .../krunk => games/krunk/ui}/useKrunkHand.ts | 24 +- games/registry.json | 4 + games/spacepoker/clsp/factory.clsp | 5 + .../spacepoker/clsp}/onchain/begin_round.clsp | 0 .../spacepoker/clsp}/onchain/commitA.clsp | 2 +- .../spacepoker/clsp}/onchain/commitB.clsp | 4 +- .../spacepoker/clsp}/onchain/end.clsp | 2 +- .../spacepoker/clsp}/onchain/mid_round.clsp | 4 +- .../clsp}/onchain/space_hand_eval.clinc | 0 .../spacepoker/clsp}/space_hand_calc.clinc | 2 +- .../clsp}/spacepoker_generate.clinc | 12 +- games/spacepoker/rust/mod.rs | 23 + .../spacepoker/rust/tests/handlers.rs | 6 +- games/spacepoker/rust/tests/mod.rs | 11 + .../spacepoker/rust/tests/sim.rs | 0 .../spacepoker/rust/tests/validation.rs | 2 +- games/spacepoker/ui/ComposeEditor.tsx | 55 ++ .../spacepoker/ui}/LiveMount.tsx | 48 +- .../spacepoker/ui}/SpacePoker.tsx | 13 +- .../ui}/SpacePokerActionControls.tsx | 0 .../spacepoker/ui}/SpacePokerTable.tsx | 0 .../spacepoker/ui}/adapter.ts | 108 ++-- .../spacepoker/ui}/handPresentation.ts | 3 +- games/spacepoker/ui/index.ts | 2 + games/spacepoker/ui/package.ts | 18 + .../spacepoker/ui}/spacePoker.test.ts | 48 +- .../spacepoker/ui}/stateCodec.ts | 2 +- .../spacepoker/ui}/statusPresentation.ts | 2 +- games/spacepoker/ui/unitSize.ts | 95 ++++ .../spacepoker/ui}/useSpacepokerHand.ts | 29 +- hub/hub-frontend/src/hub.tsx | 19 +- hub/hub-frontend/src/useHubSocket.ts | 139 +---- run-local-demo.sh | 9 + src/common/types/game_type.rs | 49 +- src/games/mod.rs | 22 +- src/manifest_guards.rs | 144 ++++- src/session_phases/effects.rs | 5 +- src/session_phases/game_collection.rs | 180 +++--- src/session_phases/mod.rs | 35 +- src/simulator/mod.rs | 42 +- src/simulator/tests/session_phases_sim.rs | 64 ++- .../tests/session_phases_sim/script_runner.rs | 34 +- src/test_support/mod.rs | 13 +- src/test_support/peer/peer_harness.rs | 5 +- src/tests/mod.rs | 7 - tools/build-chialisp.sh | 29 +- tools/compile-krunk-only.sh | 12 +- tools/stage-production.sh | 4 +- tools/verify-deploy-archives.mjs | 3 + wasm/src/mod.rs | 72 ++- 237 files changed, 4101 insertions(+), 2185 deletions(-) create mode 100644 .cursor/rules/alpha-no-compat.mdc delete mode 100644 clsp/games/calpoker/calpoker_include.clsp delete mode 100644 clsp/games/calpoker/game_codes.clinc delete mode 100644 clsp/games/krunk/krunk_include.clsp delete mode 100644 clsp/games/spacepoker/spacepoker_include.clsp create mode 100644 front-end/scripts/generate-game-registry.mjs delete mode 100644 front-end/src/features/spacePoker/unitSize.ts create mode 100644 front-end/src/generated/gamePackages.ts create mode 100644 front-end/src/generated/gamePresets.ts create mode 100644 front-end/src/generated/gameStyles.css delete mode 100644 front-end/src/hooks/useCheatNerfKeys.ts delete mode 100644 front-end/src/lib/gameAdapter.ts create mode 100644 front-end/src/lib/gameIdentities.ts delete mode 100644 front-end/src/lib/gameMount.ts create mode 100644 front-end/src/lib/tests/game_package_isolation.test.ts create mode 100644 front-end/src/lib/tests/protocolIdentities.ts rename {clsp/games/calpoker => games/calpoker/clsp}/calpoker_generate.clinc (92%) create mode 100644 games/calpoker/clsp/factory.clsp rename {clsp/games/calpoker => games/calpoker/clsp}/handcalc.clinc (94%) rename {clsp/games/calpoker => games/calpoker/clsp}/onchain/a.clsp (90%) rename {clsp/games/calpoker => games/calpoker/clsp}/onchain/arrange_cards.clinc (100%) rename {clsp/games/calpoker => games/calpoker/clsp}/onchain/b.clsp (89%) rename {clsp/games/calpoker => games/calpoker/clsp}/onchain/c.clsp (90%) rename {clsp/games/calpoker => games/calpoker/clsp}/onchain/d.clsp (86%) rename {clsp/games/calpoker => games/calpoker/clsp}/onchain/e.clsp (95%) rename {clsp/games/calpoker => games/calpoker/clsp}/onchain/flatten_card.clinc (100%) rename {clsp/games/calpoker => games/calpoker/clsp}/onchain/make_card.clinc (100%) rename {clsp/games/calpoker => games/calpoker/clsp}/onchain/make_cards.clinc (100%) rename {clsp/games/calpoker => games/calpoker/clsp}/onchain/onehandcalc.clinc (98%) rename {clsp/games/calpoker => games/calpoker/clsp}/onchain/test_atomsort.clsp (100%) create mode 100644 games/calpoker/rust/mod.rs rename src/tests/calpoker_handlers.rs => games/calpoker/rust/tests/handlers.rs (99%) create mode 100644 games/calpoker/rust/tests/mod.rs rename src/test_support/calpoker_sim.rs => games/calpoker/rust/tests/sim.rs (100%) rename src/tests/calpoker_validation.rs => games/calpoker/rust/tests/validation.rs (99%) create mode 100644 games/calpoker/ui/ComposeEditor.tsx rename {front-end/src/features/calPoker => games/calpoker/ui}/LiveMount.tsx (85%) rename {front-end/src/features/calPoker => games/calpoker/ui}/adapter.ts (78%) rename {front-end/src/features/calPoker => games/calpoker/ui}/calPoker.test.ts (96%) rename {front-end/src/features/calPoker => games/calpoker/ui}/components/CaliforniaPoker.tsx (99%) rename {front-end/src/features/calPoker => games/calpoker/ui}/components/components/Card.tsx (93%) rename {front-end/src/features/calPoker => games/calpoker/ui}/components/components/CardContent.tsx (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/components/components/GameBottomBar.tsx (54%) rename {front-end/src/features/calPoker => games/calpoker/ui}/components/components/HandDisplay.tsx (99%) rename {front-end/src/features/calPoker => games/calpoker/ui}/components/components/MovingCard.tsx (93%) rename {front-end/src/features/calPoker => games/calpoker/ui}/components/components/index.ts (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/components/constants/constants.ts (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/components/index.ts (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/components/utils/FormatHandDescription.ts (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/components/utils/MakeDescription.ts (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/components/utils/gameLog.ts (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/components/utils/index.ts (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/index.tsx (91%) rename {front-end/src/features/calPoker => games/calpoker/ui}/outcome.ts (100%) create mode 100644 games/calpoker/ui/package.ts create mode 100644 games/calpoker/ui/settlement.ts rename {front-end/src/features/calPoker => games/calpoker/ui}/stateCodec.ts (97%) create mode 100644 games/calpoker/ui/styles.css rename {front-end/src/features/calPoker => games/calpoker/ui}/types/BestHandType.ts (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/types/CaliforniapokerProps.ts (87%) rename {front-end/src/features/calPoker => games/calpoker/ui}/types/CardContentProps.ts (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/types/CardRenderProps.ts (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/types/CardValueSuit.ts (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/types/FormatHandProps.ts (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/types/HandDisplayProps.ts (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/types/MovingCardProps.ts (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/types/cardHelpers.ts (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/types/index.ts (100%) rename {front-end/src/features/calPoker => games/calpoker/ui}/useCalpokerHand.ts (97%) rename clsp/test/debug_game.clsp => games/debug/clsp/factory.clsp (93%) rename src/test_support/debug_game.rs => games/debug/rust/mod.rs (96%) create mode 100644 games/debug/rust/tests/mod.rs create mode 100644 games/host/index.ts rename front-end/src/components/AmountInput.tsx => games/host/ui.tsx (65%) create mode 100644 games/krunk/clsp/factory.clsp rename {clsp/games/krunk => games/krunk/clsp}/krunk_dict_tree.clinc (100%) rename {clsp/games/krunk => games/krunk/clsp}/krunk_generate.clinc (92%) rename {clsp/games/krunk => games/krunk/clsp}/krunk_helpers.clinc (100%) rename {clsp/games/krunk => games/krunk/clsp}/krunk_signed_dict_tree.dat (100%) rename {clsp/games/krunk => games/krunk/clsp}/krunkwords.txt (100%) rename {clsp/games/krunk => games/krunk/clsp}/onchain/clue.clsp (93%) rename {clsp/games/krunk => games/krunk/clsp}/onchain/commit.clsp (84%) rename {clsp/games/krunk => games/krunk/clsp}/onchain/guess.clsp (100%) rename {clsp/games/krunk => games/krunk/clsp}/onchain/krunk_make_clue.clinc (100%) rename {clsp/games/krunk => games/krunk/clsp}/onchain/krunk_validator_hashes.clinc (100%) rename src/bin/gen_krunk_dict.rs => games/krunk/rust/bin_gen_krunk_dict.rs (96%) rename src/games/krunk_dict_tree.rs => games/krunk/rust/dict_tree.rs (100%) create mode 100644 games/krunk/rust/mod.rs rename {src => games/krunk/rust}/tests/dict_tree_lookup.rs (100%) rename src/tests/krunk_handlers.rs => games/krunk/rust/tests/handlers.rs (99%) create mode 100644 games/krunk/rust/tests/mod.rs rename src/test_support/krunk_sim.rs => games/krunk/rust/tests/sim.rs (100%) rename src/tests/krunk_validation.rs => games/krunk/rust/tests/validation.rs (95%) create mode 100644 games/krunk/ui/ComposeEditor.tsx rename {front-end/src/features/krunk => games/krunk/ui}/Krunk.tsx (98%) rename {front-end/src/features/krunk => games/krunk/ui}/LiveMount.tsx (73%) rename {front-end/src/features/krunk => games/krunk/ui}/adapter.ts (86%) create mode 100644 games/krunk/ui/index.ts rename {front-end/src/features/krunk => games/krunk/ui}/krunk.test.ts (98%) create mode 100644 games/krunk/ui/package.ts create mode 100644 games/krunk/ui/settlement.ts rename {front-end/src/features/krunk => games/krunk/ui}/stateCodec.ts (98%) rename {front-end/src/features/krunk => games/krunk/ui}/useKrunkHand.ts (96%) create mode 100644 games/registry.json create mode 100644 games/spacepoker/clsp/factory.clsp rename {clsp/games/spacepoker => games/spacepoker/clsp}/onchain/begin_round.clsp (100%) rename {clsp/games/spacepoker => games/spacepoker/clsp}/onchain/commitA.clsp (86%) rename {clsp/games/spacepoker => games/spacepoker/clsp}/onchain/commitB.clsp (79%) rename {clsp/games/spacepoker => games/spacepoker/clsp}/onchain/end.clsp (98%) rename {clsp/games/spacepoker => games/spacepoker/clsp}/onchain/mid_round.clsp (93%) rename {clsp/games/spacepoker => games/spacepoker/clsp}/onchain/space_hand_eval.clinc (100%) rename {clsp/games/spacepoker => games/spacepoker/clsp}/space_hand_calc.clinc (95%) rename {clsp/games/spacepoker => games/spacepoker/clsp}/spacepoker_generate.clinc (97%) create mode 100644 games/spacepoker/rust/mod.rs rename src/tests/spacepoker_handlers.rs => games/spacepoker/rust/tests/handlers.rs (99%) create mode 100644 games/spacepoker/rust/tests/mod.rs rename src/test_support/spacepoker_sim.rs => games/spacepoker/rust/tests/sim.rs (100%) rename src/tests/spacepoker_validation.rs => games/spacepoker/rust/tests/validation.rs (99%) create mode 100644 games/spacepoker/ui/ComposeEditor.tsx rename {front-end/src/features/spacePoker => games/spacepoker/ui}/LiveMount.tsx (74%) rename {front-end/src/features/spacePoker => games/spacepoker/ui}/SpacePoker.tsx (95%) rename {front-end/src/features/spacePoker => games/spacepoker/ui}/SpacePokerActionControls.tsx (100%) rename {front-end/src/features/spacePoker => games/spacepoker/ui}/SpacePokerTable.tsx (100%) rename {front-end/src/features/spacePoker => games/spacepoker/ui}/adapter.ts (83%) rename {front-end/src/features/spacePoker => games/spacepoker/ui}/handPresentation.ts (98%) create mode 100644 games/spacepoker/ui/index.ts create mode 100644 games/spacepoker/ui/package.ts rename {front-end/src/features/spacePoker => games/spacepoker/ui}/spacePoker.test.ts (94%) rename {front-end/src/features/spacePoker => games/spacepoker/ui}/stateCodec.ts (99%) rename {front-end/src/features/spacePoker => games/spacepoker/ui}/statusPresentation.ts (98%) create mode 100644 games/spacepoker/ui/unitSize.ts rename {front-end/src/features/spacePoker => games/spacepoker/ui}/useSpacepokerHand.ts (97%) 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..0c925aa46 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 +`14`. 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 v14 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 +terminal data, and frozen terminal coin list); malformed v14 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 @@ -495,8 +495,8 @@ 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. | +| `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`. | +| `betweenHandLastTerms` | `SavedHandTerms \| null` | Last agreed hand terms, independent of the compose draft. Null when there is no agreed hand yet. | | `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. | @@ -658,19 +658,30 @@ 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. Each production game exports one `GamePackage` from +`games//ui/package.ts`. That package owns display metadata, the compose +editor, the state codec, the factory-parameter codec (`factoryParameters` +encode/decode of that game's canonical factory blob), `describeTerms` for the +incoming-proposal dialog, plus `toFactoryParameters` / `decodeProposalTerms`, +term validation/equality, persisted extras, membership +rules, and live/frozen mounts. Game UI imports only from `games/host` (the +portable host contract) plus `react` / `rxjs` / `clvm-lib`. It does not import +this player app. Another implementation can copy `games/` and satisfy the same +contract. `games/registry.json` is the only catalog. +Factory hashes live in `gameIdentities.ts` (warmup fills the table; Active +completes leftover probes). The JS session model and saves store catalog keys +(`calpoker`, `spacepoker`, `krunk`). `packageFor` accepts those keys only. +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 @@ -1394,11 +1411,12 @@ 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. +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` dispatches live/frozen +mounts through that package. Factory hashes are protocol ids at the WASM +propose/notify boundary (`protocolIdForCatalog` out, `catalogGameTypeFromWire` +in). `CalpokerHand` receives gameplay events via an RxJS observable and submits moves through the shared Rust-first local-action boundary. @@ -1589,16 +1607,17 @@ 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-v14 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` → v14 presentation snapshot encoder | +| `front-end/src/lib/gameRegistry.ts` | Catalog-key package lookup and game-owned codec/terms/compose dispatch | +| `front-end/src/lib/gameMountRegistry.tsx` | Live/frozen mounts 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` | v14 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..1e627e6e0 100644 --- a/GAME_LIFECYCLE.md +++ b/GAME_LIFECYCLE.md @@ -15,13 +15,15 @@ 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 +1. **Propose:** The caller submits one group request containing `game_type` + (the factory's first-validator hash, not a 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. + 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 +35,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/HANDLER_GUIDE.md b/HANDLER_GUIDE.md index 68557e748..0530ae83c 100644 --- a/HANDLER_GUIDE.md +++ b/HANDLER_GUIDE.md @@ -38,6 +38,13 @@ Games are driven by two cooperating systems: are chialisp programs, curried with game-specific state. - **Validators** enforce the rules of each move. They are chialisp programs, + run both off-chain (to check a move before sending it) and on-chain (to + settle disputes). + +Each game is a package under `games//` with `clsp/factory.clsp`, +`rust/mod.rs` (prepared factory + probe), `rust/tests/mod.rs`, and for +production games `ui/package.ts`. Register the key in `games/registry.json`; +do not hand-edit factory catalogs or frontend import lists. 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). @@ -238,7 +245,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 +784,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 +807,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..0f914715d 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -512,8 +512,17 @@ 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. +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 factory's +first-member `initial_validation_program_hash` from a canonical probe — never +the human-readable key. 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. + +The Rust game collection also registers `debug` (test list) for simulator tests +only. It is not a user-facing reference game. ### Calpoker @@ -615,12 +624,12 @@ dedicated handler and validation tests. `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 +- `games/calpoker/clsp/onchain/a.clsp` through `e.clsp` +- `games/calpoker/clsp/calpoker_generate.clinc` — off-chain handlers +- `games/calpoker/rust/tests/sim.rs` — Rust-side calpoker registration/helpers +- `games/spacepoker/clsp/onchain/*.clsp` +- `games/spacepoker/clsp/spacepoker_generate.clinc` — Space Poker handlers +- `games/spacepoker/rust/tests/sim.rs` — Rust-side Space Poker helpers ### Krunk @@ -664,7 +673,7 @@ The dictionary tree and its signatures are generated once at build time by 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 +3. Writes `games/krunk/clsp/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. @@ -674,8 +683,8 @@ 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. +deletes all `*.hex` files under `clsp/` and `games/` before rebuilding to ensure a +clean output tree. #### Atomic factory proposals @@ -708,11 +717,11 @@ general mechanism. **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 +- `games/krunk/clsp/krunk_generate.clinc` — off-chain handlers (Alice/Bob) +- `games/krunk/clsp/onchain/{commit,guess,clue}.clsp` — on-chain validators +- `games/krunk/clsp/krunk_helpers.clinc` — clue encoding, payout tables +- `games/krunk/clsp/krunk_signed_dict_tree.dat` — generated: 48-byte pubkey + signed tree (binary) +- `games/krunk/rust/` — 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 @@ -838,14 +847,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 (see [Krunk](#krunk)) | +| `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 +864,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 | diff --git a/build.rs b/build.rs index 84fa99d22..5a6886f9a 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,95 @@ 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 { + let package_ts = root.join("ui/package.ts"); + if !package_ts.is_file() { + panic!("production game package {key} missing ui/package.ts"); + } + } +} + +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 +163,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 +184,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 +192,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..766897221 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", @@ -54,7 +55,8 @@ "/scripts/testSetup.ts" ], "testMatch": [ - "/src/**/*.{spec,test}.{js,jsx,ts,tsx}" + "/src/**/*.{spec,test}.{js,jsx,ts,tsx}", + "/../games/*/ui/**/*.{spec,test}.{ts,tsx}" ], "testPathIgnorePatterns": [], "moduleDirectories": [ @@ -63,11 +65,13 @@ "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..8151e1b7d --- /dev/null +++ b/front-end/scripts/generate-game-registry.mjs @@ -0,0 +1,80 @@ +#!/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 []; + } +} + +const presetFiles = production.flatMap((key) => [factoryHex(key), ...extraPresets(key)]); +const imports = production + .map((key, index) => { + const rel = relative(join(FE, '../src/generated'), join(ROOT, 'games', key, 'ui/package.ts')) + .replace(/\\/g, '/') + .replace(/\.ts$/, ''); + return `import pkg${index} from '${rel.startsWith('.') ? rel : `./${rel}`}';`; + }) + .join('\n'); +const packageList = production.map((_, index) => `pkg${index}`).join(', '); + +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 = ${JSON.stringify(production)} 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 = ${JSON.stringify(presetFiles, null, 2)} 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. +${imports} + +export const PRODUCTION_PACKAGE_KEYS = ${JSON.stringify(production)} as const; +export type CatalogGameType = (typeof PRODUCTION_PACKAGE_KEYS)[number]; +export const GENERATED_GAME_PACKAGES = [${packageList}]; +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..7cf4bde81 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,21 +17,9 @@ 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 Editor = pkg.ComposeEditor; const submit = () => { if (!canSubmit) return; @@ -55,74 +45,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}. -

- )} + session.updateSelectedComposeDraft(update)} + onSubmit={submit} + />

Do you want to accept this hand?

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

-

- Per-player stake: {formatMojos(review.terms.myContribution)} -

+

{describeReceivedProposal(review.terms)}

Timeout: {String(review.terms.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/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 (
= Object.assign(calpokerRegistration, { + ComposeEditor: CalpokerComposeEditor, + ...calpokerMountRegistration, +}); + +export default calpokerPackage; 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/front-end/src/features/calPoker/stateCodec.ts b/games/calpoker/ui/stateCodec.ts similarity index 97% rename from front-end/src/features/calPoker/stateCodec.ts rename to games/calpoker/ui/stateCodec.ts index a616ec85e..32cdb3c1c 100644 --- a/front-end/src/features/calPoker/stateCodec.ts +++ b/games/calpoker/ui/stateCodec.ts @@ -1,4 +1,4 @@ -import { defineGameStateCodec } from '../../lib/session/gameStateCodec'; +import { defineGameStateCodec } from '../../host'; export interface CalpokerDisplaySnapshot { gameState: string; 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 87% rename from front-end/src/features/calPoker/types/CaliforniapokerProps.ts rename to games/calpoker/ui/types/CaliforniapokerProps.ts index 512f0a4e3..afc21ae9b 100644 --- a/front-end/src/features/calPoker/types/CaliforniapokerProps.ts +++ b/games/calpoker/ui/types/CaliforniapokerProps.ts @@ -1,3 +1,5 @@ +import type { GameInteractionMode, SettlementOutcome } from '../../../host'; + export interface CalpokerOutcomeView { my_win_outcome: 'win' | 'lose' | 'tie'; my_cards: string[]; @@ -36,6 +38,6 @@ 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; } 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/front-end/src/features/calPoker/useCalpokerHand.ts b/games/calpoker/ui/useCalpokerHand.ts similarity index 97% rename from front-end/src/features/calPoker/useCalpokerHand.ts rename to games/calpoker/ui/useCalpokerHand.ts index 921882f22..14e84449f 100644 --- a/front-end/src/features/calPoker/useCalpokerHand.ts +++ b/games/calpoker/ui/useCalpokerHand.ts @@ -2,16 +2,18 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { Program } from 'clvm-lib'; import { Observable } from 'rxjs'; import { CalpokerOutcome } from './outcome'; -import type { PersistedGameState } from '../../lib/session/gameStateCodec'; -import type { GameTerminalModel } from '../../lib/session/types'; -import { GameplayEvent } from '../../hooks/useGameSession'; +import type { + GameHandOrigin, + GameHandSource, + GameplayEvent, + GameTerminalModel, + LocalGameCommand, + PersistedGameState, +} from '../../host'; +import { useInitialGameHandState } from '../../host/ui'; import { requireLiveGameHandSource, - type GameHandOrigin, - type GameHandSource, - useInitialGameHandState, -} from '../../lib/gameMount'; -import type { LocalGameCommand } from '../../lib/session/sessionMachineTypes'; +} from '../../host'; import { calpokerOutcomeFromState, isCalpokerOutcomeReadable, 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..608e29fd4 --- /dev/null +++ b/games/host/index.ts @@ -0,0 +1,526 @@ +import { type ComponentType, type ReactElement } from 'react'; +import { Program } from 'clvm-lib'; +import type { Observable } from 'rxjs'; + +/** 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 GameTurnState = + | 'my-turn' + | 'their-turn' + | 'playing-on-chain' + | 'replaying' + | 'opponent-illegal-move' + | 'submitting-timeout' + | 'finishing' + | 'finishing-waiting-timeout' + | 'finishing-spending' + | 'ended'; + +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 HandTermsBaseModel { + myContribution: bigint; + theirContribution: bigint; + gameTimeout: bigint; +} + +export type RegisteredGameType = string; + +export type HandTermsModel = HandTermsBaseModel & { + 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 { + return Program.deserialize(value); + } 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; + const items = program.toList(); + return items.length === length ? items : 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 GameplayEvent = + | { ProposalAccepted: { id: bigint | number | string } } + | { OpponentMoved: { readable: Uint8Array | number[]; gameId?: string; moverShare: string } } + | { GameMessage: { readable: Uint8Array | number[]; gameId?: string } } + | { MoveRejected: { gameId: string; tag: string; message: string } } + | { Settled: { gameId: string; outcome: SettlementOutcome; ourShare: string } } + | { + GameError: { + gameId: string; + reason: string; + source: 'action' | 'terminal'; + action?: 'make-move' | 'accept-settlement'; + }; + }; + +export type LocalGameCommand = + | { type: 'make-move'; readable: Program | null } + | { type: 'accept-settlement' } + | { type: 'cheat'; moverShare: bigint }; + +export interface LocalGameActionRequest { + gameType: RegisteredGameType; + id: string; + state: unknown; + command: LocalGameCommand; +} + +export type DurableGameStateEvent = + | { + type: 'accepted-group'; + id: string; + groupIds: readonly string[]; + iStarted: boolean; + isMyTurn: boolean; + origin: ProposalGroupOrigin; + terms: HandTermsModel; + } + | { + type: 'game-status'; + id: string; + status: GameTurnState; + readable: Uint8Array | null; + moverShare: string | null; + iStarted: boolean; + } + | { type: 'local-turn'; id: string; isMyTurn: boolean } + | { type: 'settled'; id: string; terminal: GameTerminalModel } + | { type: 'remove-group'; groupIds: readonly string[] } + | { type: 'abandoned' } + | { + type: 'feature-state'; + gameType: RegisteredGameType; + id: string; + state: unknown; + }; + +export type ComposeDraftValue = Record; +export type GameComposeDrafts = Record; +export type SavedTermsExtras = Readonly>; +export type StateUpdate = T | ((current: T) => T); +export type TermsFor = HandTermsModel & { gameType: T }; + +export interface ComposeEditorProps { + draft: TDraft; + disabled: boolean; + maxPerHandMojos: bigint | null; + onChange: (update: Partial) => void; + onSubmit: () => void; +} + +export function reduceGameStateSnapshot(current: T, update: StateUpdate): T { + return typeof update === 'function' ? (update as (value: T) => T)(current) : update; +} + +export function equalBaseTerms(a: HandTermsBaseModel, b: HandTermsBaseModel): boolean { + return ( + a.myContribution === b.myContribution && + a.theirContribution === b.theirContribution && + a.gameTimeout === b.gameTimeout + ); +} + +export interface LiveGameController { + readonly handState: PersistedGameState | null; + isChannelReady(): boolean; + nerf(): void; + transitionFeatureState(gameType: string, gameId: string, state: unknown): boolean; + transitionFeatureStateWithLocalTurn( + gameType: string, + gameId: string, + state: unknown, + isMyTurn: boolean, + ): boolean; + commitLocalGameAction(request: LocalGameActionRequest): void; +} + +export type GameInteractionMode = 'live' | 'terminal'; +export type GameHandOrigin = 'fresh' | 'restored' | 'terminal'; + +export type GameHandSource = + | { + readonly interactionMode: 'live'; + readonly controller: LiveGameController; + } + | { + 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.interactionMode === 'live' ? source.controller.handState : source.handState; +} + +export function requireLiveGameHandSource(source: GameHandSource): LiveGameController { + if (source.interactionMode !== 'live') { + throw new Error('Protocol commands require a live game hand source'); + } + return source.controller; +} + +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; +} + +export interface LiveGameView { + handKey: number; + handOrigin: GameHandOrigin; + handSource: GameHandSource; + activeGameId: string | null; + activeGameIds: string[]; + currentHandGameIds: string[]; + iStarted: boolean; + playerNumber: number; + iProposedHand: boolean; + currentHandAmount: bigint; + lastHandTerms: HandTermsModel | null; + gameplayEvent$: Observable; + appendGameLog: (line: string) => void; + onHandOutcome: (outcome: HandWinOutcome) => void; + onTurnChanged: (gameId: string, isMyTurn: boolean) => void; + gameSpecificView: { + gameType: string; + displayGameId: string | null; + terminal: GameTerminalModel; + terminalsById: Record; + amountsById: Record; + }; +} + +export interface FrozenGameView { + lastDisplayedId: string | null; + currentHandIds: readonly string[]; + activeIds: readonly string[]; + handState: PersistedGameState | null; + lastTerms: HandTermsModel; + instances: Record; + iProposedHand: boolean; +} + +export interface GameMountRegistration { + renderLive(session: LiveGameView, names: GameMountNames): ReactElement; + renderFrozen(view: FrozenGameView, options: FrozenGameMountOptions): ReactElement; +} + +export interface GameFeatureRegistration< + TState, + TFeatureState = TState, + TDraft = ComposeDraftValue, + TParams = unknown, +> { + gameType: string; + readonly displayName: string; + readonly stateCodec: GameStateCodec; + readonly factoryParameters: FactoryParameterCodec; + describeTerms(terms: HandTermsModel, text: GameHostText): string; + readonly handMembershipDescription: string; + validateHandMembership(gameIds: readonly string[], state: TState | null): boolean; + decodeFeatureState(value: unknown): TFeatureState | null; + readonly lifecycle: { + proposalSenderGoesFirst(iStarted: boolean): boolean; + }; + readonly compose: { + defaultDraft(perGameAmount: bigint): TDraft; + draftFromTerms(terms: HandTermsModel): TDraft; + updateDraft(current: TDraft, update: Partial): TDraft; + toTerms(draft: TDraft, gameTimeout: bigint): HandTermsModel | null; + }; + toFactoryParameters(terms: HandTermsModel, iStarted: boolean): TParams; + decodeProposalTerms(base: HandTermsBaseModel, params: TParams): HandTermsModel | null; + validateTerms(terms: HandTermsModel): boolean; + termsEqual(a: HandTermsModel, b: HandTermsModel): boolean; + persistence: { + encodeExtras(terms: HandTermsModel): SavedTermsExtras; + decodeExtras(base: HandTermsBaseModel, extras: SavedTermsExtras): HandTermsModel | null; + }; + readonly durableState: { + reduceEvent(current: TState | null, event: DurableGameStateEvent): TState | null; + }; +} + +export interface GamePackage< + TState = unknown, + TDraft = ComposeDraftValue, + TFeatureState = TState, + TParams = unknown, +> + extends GameFeatureRegistration, GameMountRegistration { + ComposeEditor: ComponentType>; +} + +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 65% rename from front-end/src/components/AmountInput.tsx rename to games/host/ui.tsx index bb1b81b05..5d9ab1099 100644 --- a/front-end/src/components/AmountInput.tsx +++ b/games/host/ui.tsx @@ -1,5 +1,100 @@ -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, + gameHandState, + type CurrencyLabels, + type GameHandSource, + type PersistedGameState, +} 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 useInitialGameHandState( + source: GameHandSource, +): Readonly | null { + const [initial] = useState(() => gameHandState(source)); + return initial; +} + +export function useCheatNerfKeys( + handleCheat: () => void, + handleNerf: () => void, + enabled = true, +): void { + const cheatBufRef = useRef(''); + const nerfBufRef = useRef(''); + useEffect(() => { + if (!enabled) return; + const CHEAT_SEQ = 'cheat^'; + const NERF_SEQ = 'nerf^'; + 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 : ''; + } + + const nerfBuf = nerfBufRef.current + e.key; + if (NERF_SEQ.startsWith(nerfBuf)) { + nerfBufRef.current = nerfBuf; + if (nerfBuf === NERF_SEQ) { + nerfBufRef.current = ''; + handleNerf(); + } + } else { + nerfBufRef.current = NERF_SEQ.startsWith(e.key) ? e.key : ''; + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [enabled, handleCheat, handleNerf]); +} function mojosToXchStr(mojos: bigint): string { const s = mojos.toString().padStart(13, '0'); @@ -37,7 +132,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 +140,7 @@ interface AmountInputProps { disabled?: boolean; label?: string; exceedsLabel?: string; - onKeyDown?: (e: React.KeyboardEvent) => void; + onKeyDown?: (e: KeyboardEvent) => void; } export function AmountInput({ @@ -58,7 +153,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 100% rename from src/games/krunk_dict_tree.rs rename to games/krunk/rust/dict_tree.rs 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 100% rename from src/test_support/krunk_sim.rs rename to games/krunk/rust/tests/sim.rs 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/games/krunk/ui/ComposeEditor.tsx b/games/krunk/ui/ComposeEditor.tsx new file mode 100644 index 000000000..0856b3aee --- /dev/null +++ b/games/krunk/ui/ComposeEditor.tsx @@ -0,0 +1,38 @@ +import { AmountInput, useGameHost } from '../../host/ui'; +import type { ComposeEditorProps } from '../../host'; +import { isValidKrunkStake } from './adapter'; + +export function KrunkComposeEditor({ + draft, + disabled, + maxPerHandMojos, + onChange, + onSubmit, +}: ComposeEditorProps<{ 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.tsx b/games/krunk/ui/Krunk.tsx similarity index 98% rename from front-end/src/features/krunk/Krunk.tsx rename to games/krunk/ui/Krunk.tsx index 448601443..677d7589c 100644 --- a/front-end/src/features/krunk/Krunk.tsx +++ b/games/krunk/ui/Krunk.tsx @@ -11,11 +11,14 @@ 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 { + defaultFormatAmount, + type GameHandSource, + type GameplayEvent, + type GameTerminalModel, + type PersistedGameState, +} from '../../host'; +import { useGameHost, useInitialGameHandState } from '../../host/ui'; import { krunkStateCodec } from './stateCodec'; export interface KrunkProps { @@ -41,6 +44,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)}`]; @@ -449,6 +453,7 @@ const Krunk: React.FC = ({ 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) @@ -523,6 +528,7 @@ const Krunk: React.FC = ({ betSize, aliceHand.gameState.guesses, aliceHand.gameState.revealedWord ?? aliceHand.gameState.secretWord, + formatAmount, ), ); }, [ @@ -531,6 +537,7 @@ const Krunk: React.FC = ({ aliceHand.gameState.revealedWord, aliceHand.gameState.secretWord, betSize, + formatAmount, onGameLog, ]); useEffect(() => { @@ -539,13 +546,20 @@ const Krunk: React.FC = ({ } bobLogFiredRef.current = true; onGameLog( - formatKrunkHandLog('bob', betSize, bobHand.gameState.guesses, bobHand.gameState.revealedWord), + 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/front-end/src/features/krunk/LiveMount.tsx b/games/krunk/ui/LiveMount.tsx similarity index 73% rename from front-end/src/features/krunk/LiveMount.tsx rename to games/krunk/ui/LiveMount.tsx index afb8620c2..0a0d16f81 100644 --- a/front-end/src/features/krunk/LiveMount.tsx +++ b/games/krunk/ui/LiveMount.tsx @@ -1,13 +1,13 @@ import { lazy, useCallback } from 'react'; import { EMPTY, type Observable } from 'rxjs'; -import type { GameplayEvent } from '../../hooks/useGameSession'; import { terminalGameHandSource, + type FrozenGameView, type GameHandSource, type GameMountRegistration, -} from '../../lib/gameMount'; -import { selectIProposedHand } from '../../lib/session/selectors'; -import type { GameTerminalModel } from '../../lib/session/types'; + type GameplayEvent, + type GameTerminalModel, +} from '../../host'; const Krunk = lazy(() => import('./Krunk')); @@ -57,22 +57,22 @@ export const krunkMountRegistration: GameMountRegistration = { /> ); }, - renderFrozen(model, options) { + renderFrozen(view: FrozenGameView, options) { return ( {}} appendGameLog={() => {}} terminalsById={Object.fromEntries( - Object.entries(model.game.instances).map(([id, instance]) => [id, instance.terminal]), + Object.entries(view.instances).map(([id, instance]) => [id, instance.terminal]), )} amountsById={Object.fromEntries( - Object.entries(model.game.instances).map(([id, instance]) => [id, instance.amount]), + Object.entries(view.instances).map(([id, instance]) => [id, instance.amount]), )} myName={options.myName} opponentName={options.opponentName} diff --git a/front-end/src/features/krunk/adapter.ts b/games/krunk/ui/adapter.ts similarity index 86% rename from front-end/src/features/krunk/adapter.ts rename to games/krunk/ui/adapter.ts index ab2df56a8..2e78415c7 100644 --- a/front-end/src/features/krunk/adapter.ts +++ b/games/krunk/ui/adapter.ts @@ -1,11 +1,13 @@ import { Program } from 'clvm-lib'; import { equalBaseTerms, - reduceGameStateSnapshot, + readClvmAtom, + readClvmProgram, type DurableGameStateEvent, + type FactoryParameterCodec, type GameFeatureRegistration, - type TermsFor, -} from '../../lib/gameAdapter'; + type HandTermsModel, +} from '../../host'; import { decodeKrunkGameState, initialKrunkGameState, @@ -179,11 +181,26 @@ export function reduceKrunkDurableState( return { games: { ...current.games, [event.id]: next } }; } +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 validateKrunkTerms(terms: TermsFor<'krunk'>): boolean { +export function validateKrunkTerms(terms: HandTermsModel): boolean { return ( terms.myContribution === terms.theirContribution && isValidKrunkStake(terms.myContribution) && @@ -191,10 +208,17 @@ export function validateKrunkTerms(terms: TermsFor<'krunk'>): boolean { ); } -export const krunkRegistration: GameFeatureRegistration<'krunk', KrunkHandState, KrunkGameState> = { +export const krunkRegistration: GameFeatureRegistration< + KrunkHandState, + KrunkGameState, + { amount: bigint }, + KrunkFactoryParameters +> = { gameType: 'krunk', displayName: 'Krunk', stateCodec: krunkStateCodec, + factoryParameters: krunkFactoryParameters, + describeTerms: (terms, { formatMojos }) => `Stake ${formatMojos(terms.myContribution)} each`, handMembershipDescription: 'exactly two ordered currentHandGameIds whose payload IDs exactly match currentHandGameIds in order', validateHandMembership(gameIds, state) { @@ -217,7 +241,7 @@ export const krunkRegistration: GameFeatureRegistration<'krunk', KrunkHandState, updateDraft: (current, update) => ({ ...current, ...update }), toTerms(draft, gameTimeout) { const terms = { - gameType: 'krunk' as const, + gameType: 'krunk', myContribution: draft.amount, theirContribution: draft.amount, gameTimeout, @@ -225,22 +249,22 @@ export const krunkRegistration: GameFeatureRegistration<'krunk', KrunkHandState, return validateKrunkTerms(terms) ? terms : null; }, }, - decodeProposalTerms(base) { - const terms = { gameType: 'krunk' as const, ...base }; + toFactoryParameters: (terms) => ({ stake: terms.myContribution }), + decodeProposalTerms(base, params) { + if (params.stake !== base.myContribution) return null; + const terms = { gameType: 'krunk', ...base }; return validateKrunkTerms(terms) ? terms : null; }, - encodeProposalParameters: (terms) => Program.fromBigInt(terms.myContribution), validateTerms: validateKrunkTerms, termsEqual: equalBaseTerms, persistence: { encodeExtras: () => ({}), decodeExtras(base) { - const terms = { gameType: 'krunk' as const, ...base }; + const terms = { gameType: 'krunk', ...base }; return validateKrunkTerms(terms) ? terms : null; }, }, durableState: { - reduce: reduceGameStateSnapshot, reduceEvent: reduceKrunkDurableState, }, }; diff --git a/games/krunk/ui/index.ts b/games/krunk/ui/index.ts new file mode 100644 index 000000000..2f7508ce2 --- /dev/null +++ b/games/krunk/ui/index.ts @@ -0,0 +1,2 @@ +export { krunkRegistration as default, krunkRegistration } from './adapter'; +export { krunkMountRegistration } from './LiveMount'; diff --git a/front-end/src/features/krunk/krunk.test.ts b/games/krunk/ui/krunk.test.ts similarity index 98% rename from front-end/src/features/krunk/krunk.test.ts rename to games/krunk/ui/krunk.test.ts index 6dd047899..0ca16e579 100644 --- a/front-end/src/features/krunk/krunk.test.ts +++ b/games/krunk/ui/krunk.test.ts @@ -18,8 +18,8 @@ import { gameplayEventForMoveRejected, gameplayEventsForGameStatus, parseTermsFromNotificationValue, -} from '../../hooks/useGameSession'; -import { createSessionModel, selectProposalGroupByMemberId } from '../../lib/session/model'; +} from '@/hooks/useGameSession'; +import { createSessionModel, selectProposalGroupByMemberId } from '@/lib/session/model'; import { isValidKrunkStake } from './adapter'; import { formatKrunkHandLog, @@ -30,9 +30,11 @@ import { } 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 { + type GameTerminalModel, + type LiveGameController, + type LocalGameActionRequest, +} from '../../host'; function terminal( outcome: GameTerminalModel['outcome'] = null, @@ -131,7 +133,7 @@ describe('Krunk draft continuity', () => { makeMove: jest.fn(), commitLocalGameAction: jest.fn(), transitionFeatureState: jest.fn((_, __, state) => state), - } as unknown as SessionController; + } as unknown as LiveGameController; const baseProps = { handSource: { interactionMode: 'live' as const, controller }, currentHandGameIds: ['picker', 'guesser'], @@ -271,7 +273,7 @@ describe('Krunk draft continuity', () => { makeMove, commitLocalGameAction, transitionFeatureState, - } as unknown as SessionController, + } as unknown as LiveGameController, }, currentHandGameIds: ['picker', 'guesser'], activeGameIds: ['picker', 'guesser'], @@ -338,7 +340,7 @@ describe('Krunk draft continuity', () => { makeMove, commitLocalGameAction, transitionFeatureState, - } as unknown as SessionController, + } as unknown as LiveGameController, }, currentHandGameIds: ['picker', 'guesser'], activeGameIds: ['guesser'], diff --git a/games/krunk/ui/package.ts b/games/krunk/ui/package.ts new file mode 100644 index 000000000..964d9ad41 --- /dev/null +++ b/games/krunk/ui/package.ts @@ -0,0 +1,17 @@ +import type { GamePackage } from '../../host'; +import { krunkRegistration, type KrunkFactoryParameters } from './adapter'; +import { KrunkComposeEditor } from './ComposeEditor'; +import { krunkMountRegistration } from './LiveMount'; +import type { KrunkGameState, KrunkHandState } from './stateCodec'; + +export const krunkPackage: GamePackage< + KrunkHandState, + { amount: bigint }, + KrunkGameState, + KrunkFactoryParameters +> = Object.assign(krunkRegistration, { + ComposeEditor: KrunkComposeEditor, + ...krunkMountRegistration, +}); + +export default krunkPackage; 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/stateCodec.ts b/games/krunk/ui/stateCodec.ts similarity index 98% rename from front-end/src/features/krunk/stateCodec.ts rename to games/krunk/ui/stateCodec.ts index 006455087..707abb5b3 100644 --- a/front-end/src/features/krunk/stateCodec.ts +++ b/games/krunk/ui/stateCodec.ts @@ -1,4 +1,4 @@ -import { defineGameStateCodec } from '../../lib/session/gameStateCodec'; +import { defineGameStateCodec } from '../../host'; export const KrunkHandler = { WaitingCommit: 0n, diff --git a/front-end/src/features/krunk/useKrunkHand.ts b/games/krunk/ui/useKrunkHand.ts similarity index 96% rename from front-end/src/features/krunk/useKrunkHand.ts rename to games/krunk/ui/useKrunkHand.ts index 210a662e2..4f807e7f7 100644 --- a/front-end/src/features/krunk/useKrunkHand.ts +++ b/games/krunk/ui/useKrunkHand.ts @@ -1,11 +1,17 @@ import { useState, 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 { + DEFAULT_CURRENCY_LABELS, + requireLiveGameHandSource, + type CurrencyLabels, + type GameHandSource, + type GameplayEvent, + type GameTerminalModel, + type LocalGameCommand, + type PersistedGameState, +} from '../../host'; +import { krunkSettlementStatus } from './settlement'; import { krunkOutcomeFromPlay, reduceKrunkFeatureState } from './adapter'; import { krunkGameStateFromPersisted, @@ -14,8 +20,6 @@ import { type KrunkGuess, type KrunkRole, } from './stateCodec'; -import type { PersistedGameState } from '../../lib/session/gameStateCodec'; -import type { LocalGameCommand } from '../../lib/session/sessionMachineTypes'; export { KrunkHandler }; export type { KrunkGameState, KrunkGuess, KrunkRole }; @@ -224,8 +228,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; 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/games/spacepoker/ui/ComposeEditor.tsx b/games/spacepoker/ui/ComposeEditor.tsx new file mode 100644 index 000000000..342911c1d --- /dev/null +++ b/games/spacepoker/ui/ComposeEditor.tsx @@ -0,0 +1,55 @@ +import { AmountInput, useGameHost } from '../../host/ui'; +import type { ComposeEditorProps } from '../../host'; + +export function SpacepokerComposeEditor({ + draft, + disabled, + maxPerHandMojos, + onChange, + onSubmit, +}: ComposeEditorProps<{ 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/front-end/src/features/spacePoker/LiveMount.tsx b/games/spacepoker/ui/LiveMount.tsx similarity index 74% rename from front-end/src/features/spacePoker/LiveMount.tsx rename to games/spacepoker/ui/LiveMount.tsx index 1e27eb455..2092335b5 100644 --- a/front-end/src/features/spacePoker/LiveMount.tsx +++ b/games/spacepoker/ui/LiveMount.tsx @@ -1,13 +1,17 @@ import { lazy, useCallback } from 'react'; import { EMPTY, type Observable } from 'rxjs'; -import type { GameplayEvent } from '../../hooks/useGameSession'; import { + EMPTY_GAME_TERMINAL_MODEL, terminalGameHandSource, + type FrozenGameView, type GameHandSource, type GameMountRegistration, -} from '../../lib/gameMount'; -import type { GameTerminalModel, HandTermsModel } from '../../lib/session/types'; -import { formatAmount } from '../../util'; + type GameplayEvent, + type GameTerminalModel, + type HandTermsModel, +} from '../../host'; +import { useGameHost } from '../../host/ui'; +import { spacepokerTermsOf } from './unitSize'; const SpacePoker = lazy(() => import('./SpacePoker')); @@ -16,7 +20,7 @@ export interface SpacepokerLiveMountProps { gameId: string; iStarted: boolean; gameplayEvent$: Observable; - terms: Extract; + terms: HandTermsModel; onTurnChanged: (gameId: string, isMyTurn: boolean) => void; appendGameLog: (line: string) => void; myName?: string; @@ -37,10 +41,12 @@ export function SpacepokerLiveMount(props: SpacepokerLiveMountProps) { opponentName, terminal, } = props; - const unitSizeMojosValue = terms.unitSizeMojos; - if (unitSizeMojosValue <= 0n) { + const { formatAmount } = useGameHost(); + const space = spacepokerTermsOf(terms); + if (!space) { throw new Error('Space Poker mount requires one canonical positive unit size'); } + const unitSizeMojosValue = space.unitSizeMojos; const stackSize = terms.myContribution / unitSizeMojosValue; const handleTurnChanged = useCallback( (isMyTurn: boolean) => onTurnChanged(gameId, isMyTurn), @@ -52,7 +58,7 @@ export function SpacepokerLiveMount(props: SpacepokerLiveMountProps) { lines.forEach(appendGameLog); appendGameLog(''); }, - [appendGameLog, stackSize, unitSizeMojosValue], + [appendGameLog, formatAmount, stackSize, unitSizeMojosValue], ); return ( @@ -75,7 +81,7 @@ export function SpacepokerLiveMount(props: SpacepokerLiveMountProps) { export const spacepokerMountRegistration: GameMountRegistration = { renderLive(session, names) { const terms = session.lastHandTerms; - if (terms.gameType !== 'spacepoker') { + if (terms === null || terms.gameType !== 'spacepoker') { throw new Error('Space Poker session is missing Space Poker terms'); } return ( @@ -93,39 +99,25 @@ export const spacepokerMountRegistration: GameMountRegistration = { /> ); }, - renderFrozen(model, options) { - const terms = model.betweenHand.lastTerms; + renderFrozen(view: FrozenGameView, options) { + const terms = view.lastTerms; if (terms.gameType !== 'spacepoker') { throw new Error('Finished Space Poker session is missing Space Poker terms'); } - const gameId = - model.game.lastDisplayedId ?? - model.game.currentHandIds[0] ?? - model.game.activeIds[0] ?? - 'finished'; + const gameId = view.lastDisplayedId ?? view.currentHandIds[0] ?? view.activeIds[0] ?? 'finished'; return ( {}} appendGameLog={() => {}} - terminal={model.game.instances[gameId]?.terminal ?? emptyFinishedTerminal()} + terminal={view.instances[gameId]?.terminal ?? EMPTY_GAME_TERMINAL_MODEL} myName={options.myName} opponentName={options.opponentName} /> ); }, }; - -function emptyFinishedTerminal(): GameTerminalModel { - return { - type: 'none', - outcome: null, - label: null, - myReward: null, - rewardCoinHex: null, - }; -} diff --git a/front-end/src/features/spacePoker/SpacePoker.tsx b/games/spacepoker/ui/SpacePoker.tsx similarity index 95% rename from front-end/src/features/spacePoker/SpacePoker.tsx rename to games/spacepoker/ui/SpacePoker.tsx index bcae620c2..5f9e8c791 100644 --- a/front-end/src/features/spacePoker/SpacePoker.tsx +++ b/games/spacepoker/ui/SpacePoker.tsx @@ -3,12 +3,9 @@ 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'; +} from '../../host'; +import type { GameTerminalModel, GameplayEvent } from '../../host'; +import { useCheatNerfKeys, useGameHost, useInitialGameHandState } from '../../host/ui'; import { describeSpacePokerHand, formatSpacepokerHandLog } from './handPresentation'; import { SpacePokerActionControls } from './SpacePokerActionControls'; import { SpacePokerHandHistory, SpacePokerTable } from './SpacePokerTable'; @@ -69,7 +66,7 @@ export default function SpacePoker({ initialPersistedState ?? undefined, ); const { handler, myTurn, N } = sp.gameState; - const spCurrency = getCurrencyLabels(); + const { currencyLabels: spCurrency, formatAmount } = useGameHost(); const handleNerf = useCallback(() => { requireLiveGameHandSource(handSource).nerf(); @@ -100,6 +97,7 @@ export default function SpacePoker({ sp.coinTossIOpen, sp.betUnit, stackSize, + formatAmount, ), ); }, [ @@ -115,6 +113,7 @@ export default function SpacePoker({ sp.betUnit, betSizeValue, onGameLog, + formatAmount, ]); const inBetting = handler === SpHandler.BeginRound || handler === SpHandler.MidRound; 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/adapter.ts b/games/spacepoker/ui/adapter.ts similarity index 83% rename from front-end/src/features/spacePoker/adapter.ts rename to games/spacepoker/ui/adapter.ts index 0bf46f825..f9c68142d 100644 --- a/front-end/src/features/spacePoker/adapter.ts +++ b/games/spacepoker/ui/adapter.ts @@ -1,14 +1,19 @@ import { Program } from 'clvm-lib'; import { equalBaseTerms, - reduceGameStateSnapshot, + isForfeitOutcome, type DurableGameStateEvent, type GameFeatureRegistration, - type TermsFor, -} from '../../lib/gameAdapter'; -import { isForfeitOutcome, type SettlementOutcome } from '../../lib/settlement'; + type HandTermsModel, + type SettlementOutcome, +} from '../../host'; import { spacepokerStateCodec, type SpacepokerHandState, type SpHandEntry } from './stateCodec'; -import { resolveSpacepokerUnitSize } from './unitSize'; +import { + resolveSpacepokerUnitSize, + spacepokerFactoryParameters, + spacepokerTermsOf, + type SpacepokerFactoryParameters, +} from './unitSize'; function initialState(isMyTurn: boolean, unitSizeMojos: bigint): SpacepokerHandState { return { @@ -354,7 +359,7 @@ export function reduceSpacepokerDurableState( 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); + return current ?? initialState(event.isMyTurn, spacepokerTermsOf(event.terms)?.unitSizeMojos ?? 1n); } if (event.type === 'feature-state') { const state = spacepokerStateCodec.isState(event.state) ? event.state : null; @@ -388,20 +393,34 @@ export function reduceSpacepokerDurableState( return readableEvent.type === 'opponent-moved' ? { ...next, pendingTerminalAction: null } : next; } -export function validateSpacepokerTerms(terms: TermsFor<'spacepoker'>): boolean { +export function validateSpacepokerTerms(terms: HandTermsModel): boolean { + const space = spacepokerTermsOf(terms); return ( - terms.myContribution === terms.theirContribution && - terms.myContribution > 0n && - terms.gameTimeout > 0n && - resolveSpacepokerUnitSize({ terms }) !== null && - terms.myContribution % terms.unitSizeMojos === 0n + space !== null && + space.myContribution === space.theirContribution && + space.myContribution > 0n && + space.gameTimeout > 0n && + resolveSpacepokerUnitSize({ terms: space }) !== null && + space.myContribution % space.unitSizeMojos === 0n ); } -export const spacepokerRegistration: GameFeatureRegistration<'spacepoker', SpacepokerHandState> = { +export const spacepokerRegistration: GameFeatureRegistration< + SpacepokerHandState, + SpacepokerHandState, + { unitSize: bigint; stackSize: bigint }, + SpacepokerFactoryParameters +> = { gameType: 'spacepoker', displayName: 'Space Poker', stateCodec: spacepokerStateCodec, + factoryParameters: spacepokerFactoryParameters, + describeTerms(terms, { formatMojos }) { + const space = spacepokerTermsOf(terms); + if (!space) return `Stake ${formatMojos(terms.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), @@ -410,16 +429,19 @@ export const spacepokerRegistration: GameFeatureRegistration<'spacepoker', Space }, compose: { defaultDraft: () => ({ unitSize: 1n, stackSize: 10n }), - draftFromTerms: (terms) => ({ - unitSize: terms.unitSizeMojos, - stackSize: terms.myContribution / terms.unitSizeMojos, - }), + draftFromTerms: (terms) => { + const unitSize = spacepokerTermsOf(terms)?.unitSizeMojos ?? 1n; + return { + unitSize, + stackSize: unitSize > 0n ? terms.myContribution / unitSize : 10n, + }; + }, 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, + gameType: 'spacepoker', myContribution: amount, theirContribution: amount, gameTimeout, @@ -428,33 +450,48 @@ export const spacepokerRegistration: GameFeatureRegistration<'spacepoker', Space 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)) { + toFactoryParameters(terms, iStarted) { + const betUnit = resolveSpacepokerUnitSize({ terms }); + if (!betUnit || !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), - ]); + return { + perPlayerStake: terms.myContribution, + betUnit, + senderGoesFirst: this.lifecycle.proposalSenderGoesFirst(iStarted), + }; + }, + decodeProposalTerms(base, params) { + if (params.perPlayerStake !== base.myContribution) return null; + const terms = { + gameType: 'spacepoker', + ...base, + unitSizeMojos: params.betUnit, + }; + return validateSpacepokerTerms(terms) ? terms : null; }, validateTerms: validateSpacepokerTerms, - termsEqual: (a, b) => equalBaseTerms(a, b) && a.unitSizeMojos === b.unitSizeMojos, + termsEqual: (a, b) => { + const left = spacepokerTermsOf(a); + const right = spacepokerTermsOf(b); + return ( + left !== null && + right !== null && + equalBaseTerms(left, right) && + left.unitSizeMojos === right.unitSizeMojos + ); + }, persistence: { - encodeExtras: (terms) => ({ spacepoker_unit_size: terms.unitSizeMojos.toString() }), + encodeExtras: (terms) => { + const space = spacepokerTermsOf(terms); + 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 terms = { gameType: 'spacepoker' as const, ...base, unitSizeMojos }; + const terms = { gameType: 'spacepoker', ...base, unitSizeMojos }; return validateSpacepokerTerms(terms) ? terms : null; } catch { return null; @@ -462,7 +499,6 @@ export const spacepokerRegistration: GameFeatureRegistration<'spacepoker', Space }, }, durableState: { - reduce: reduceGameStateSnapshot, reduceEvent: reduceSpacepokerDurableState, }, }; 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/index.ts b/games/spacepoker/ui/index.ts new file mode 100644 index 000000000..c4dd2708b --- /dev/null +++ b/games/spacepoker/ui/index.ts @@ -0,0 +1,2 @@ +export { spacepokerRegistration as default, spacepokerRegistration } from './adapter'; +export { spacepokerMountRegistration } from './LiveMount'; diff --git a/games/spacepoker/ui/package.ts b/games/spacepoker/ui/package.ts new file mode 100644 index 000000000..867c85f1f --- /dev/null +++ b/games/spacepoker/ui/package.ts @@ -0,0 +1,18 @@ +import type { GamePackage } from '../../host'; +import { spacepokerRegistration } from './adapter'; +import { SpacepokerComposeEditor } from './ComposeEditor'; +import { spacepokerMountRegistration } from './LiveMount'; +import type { SpacepokerHandState } from './stateCodec'; +import type { SpacepokerFactoryParameters } from './unitSize'; + +export const spacepokerPackage: GamePackage< + SpacepokerHandState, + { unitSize: bigint; stackSize: bigint }, + SpacepokerHandState, + SpacepokerFactoryParameters +> = Object.assign(spacepokerRegistration, { + ComposeEditor: SpacepokerComposeEditor, + ...spacepokerMountRegistration, +}); + +export default spacepokerPackage; diff --git a/front-end/src/features/spacePoker/spacePoker.test.ts b/games/spacepoker/ui/spacePoker.test.ts similarity index 94% rename from front-end/src/features/spacePoker/spacePoker.test.ts rename to games/spacepoker/ui/spacePoker.test.ts index a4ba629c6..d53b2a666 100644 --- a/front-end/src/features/spacePoker/spacePoker.test.ts +++ b/games/spacepoker/ui/spacePoker.test.ts @@ -28,12 +28,14 @@ import { import { gameplayEventForActionFailed, gameplayEventForGameActionError, +} from '@/hooks/useGameSession'; +import { + EMPTY_GAME_TERMINAL_MODEL, type GameplayEvent, -} from '../../hooks/useGameSession'; -import type { SessionController } from '../../hooks/SessionController'; -import { decodeGameFeatureState } from '../../lib/gameRegistry'; -import { INITIAL_GAME_TERMINAL_MODEL } from '../../lib/session/model'; -import type { LocalGameActionRequest } from '../../lib/session/sessionMachineTypes'; + type LiveGameController, + type LocalGameActionRequest, +} from '../../host'; +import { spacepokerRegistration } from './adapter'; import { spacepokerStateCodec, type SpacepokerHandState } from './stateCodec'; describe('Space Poker terminal UX', () => { @@ -321,7 +323,7 @@ describe('Space Poker feature-state authority', () => { throw new Error('check rejected'); }, makeMove, - } as unknown as SessionController; + } as unknown as LiveGameController; let hand: UseSpacepokerHandResult | undefined; function Harness() { @@ -333,7 +335,7 @@ describe('Space Poker feature-state authority', () => { 100n, 10n, onTurnChanged, - INITIAL_GAME_TERMINAL_MODEL, + EMPTY_GAME_TERMINAL_MODEL, controller.handState ?? undefined, ); return null; @@ -375,7 +377,7 @@ describe('Space Poker feature-state authority', () => { commitLocalGameAction: () => { throw new Error('autoplay rejected'); }, - } as unknown as SessionController; + } as unknown as LiveGameController; function Harness() { useSpacepokerHand( @@ -386,7 +388,7 @@ describe('Space Poker feature-state authority', () => { 100n, 10n, () => {}, - INITIAL_GAME_TERMINAL_MODEL, + EMPTY_GAME_TERMINAL_MODEL, controller.handState ?? undefined, ); return null; @@ -427,11 +429,11 @@ describe('Space Poker feature-state authority', () => { isChannelReady: () => true, transitionFeatureState: (_gameType: string, _gameId: string, state: unknown) => { transitions.push(state); - return decodeGameFeatureState('spacepoker', state) !== null; + return spacepokerRegistration.decodeFeatureState( state) !== null; }, transitionFeatureStateWithLocalTurn: (_gameType: string, _gameId: string, state: unknown) => { transitions.push(state); - return decodeGameFeatureState('spacepoker', state) !== null; + return spacepokerRegistration.decodeFeatureState( state) !== null; }, commitLocalGameAction: (request: LocalGameActionRequest) => { if (request.command.type !== 'accept-settlement') throw new Error('unexpected command'); @@ -439,7 +441,7 @@ describe('Space Poker feature-state authority', () => { transitions.push(request.state); }, acceptSettlement, - } as unknown as SessionController; + } as unknown as LiveGameController; let hand: UseSpacepokerHandResult | undefined; function Harness() { @@ -451,7 +453,7 @@ describe('Space Poker feature-state authority', () => { 100n, 10n, onTurnChanged, - INITIAL_GAME_TERMINAL_MODEL, + EMPTY_GAME_TERMINAL_MODEL, controller.handState ?? undefined, ); return null; @@ -465,7 +467,7 @@ describe('Space Poker feature-state authority', () => { }); expect(transitions).toHaveLength(1); - expect(decodeGameFeatureState('spacepoker', transitions[0])).toMatchObject({ + expect(spacepokerRegistration.decodeFeatureState( transitions[0])).toMatchObject({ gameState: { handler: SpHandler.Folded, myTurn: false, N: 3n }, terminalState: 'folded-by-you', handHistory: [{ player: 'you', action: 'fold' }], @@ -491,7 +493,7 @@ describe('Space Poker feature-state authority', () => { }); expect(transitions).toHaveLength(2); - expect(decodeGameFeatureState('spacepoker', transitions[1])).toMatchObject({ + expect(spacepokerRegistration.decodeFeatureState( transitions[1])).toMatchObject({ gameState: { handler: SpHandler.MidRound, myTurn: true, N: 3n }, terminalState: 'none', handHistory: [], @@ -526,7 +528,7 @@ describe('Space Poker feature-state authority', () => { isChannelReady: () => true, transitionFeatureState: (_gameType: string, _gameId: string, state: unknown) => { transitions.push(state); - return decodeGameFeatureState('spacepoker', state) !== null; + return spacepokerRegistration.decodeFeatureState( state) !== null; }, commitLocalGameAction: (request: LocalGameActionRequest) => { if (request.command.type !== 'make-move') throw new Error('unexpected command'); @@ -534,7 +536,7 @@ describe('Space Poker feature-state authority', () => { transitions.push(request.state); }, makeMove, - } as unknown as SessionController; + } as unknown as LiveGameController; let hand: UseSpacepokerHandResult | undefined; function Harness() { @@ -546,7 +548,7 @@ describe('Space Poker feature-state authority', () => { 100n, 10n, jest.fn(), - INITIAL_GAME_TERMINAL_MODEL, + EMPTY_GAME_TERMINAL_MODEL, controller.handState ?? undefined, ); return null; @@ -560,7 +562,7 @@ describe('Space Poker feature-state authority', () => { }); expect(transitions).toHaveLength(1); - expect(decodeGameFeatureState('spacepoker', transitions[0])).toMatchObject({ + expect(spacepokerRegistration.decodeFeatureState( transitions[0])).toMatchObject({ gameState: { handler: SpHandler.BeginRound, myTurn: false, N: 2n }, halfPot: 5n, lastRaise: 0n, @@ -602,7 +604,7 @@ describe('Space Poker feature-state authority', () => { displayMode: 'units', }), ); - const controllerRef = React.useRef(null); + const controllerRef = React.useRef(null); if (!controllerRef.current) { const controller = { isChannelReady: () => true, @@ -619,7 +621,7 @@ describe('Space Poker feature-state authority', () => { persistedRef.current = canonical; rerender((value) => value + 1); }, - } as unknown as SessionController; + } as unknown as LiveGameController; Object.defineProperty(controller, 'handState', { get: () => persistedRef.current, enumerable: false, @@ -635,7 +637,7 @@ describe('Space Poker feature-state authority', () => { unitSizeMojos: '10', onTurnChanged: () => {}, onGameLog: () => {}, - terminal: INITIAL_GAME_TERMINAL_MODEL, + terminal: EMPTY_GAME_TERMINAL_MODEL, }); } @@ -655,7 +657,7 @@ describe('Space Poker feature-state authority', () => { expect(committed).toHaveLength(1); expect(postCommitStateReads).toBe(0); - expect(decodeGameFeatureState('spacepoker', committed[0].state)).toMatchObject( + expect(spacepokerRegistration.decodeFeatureState( committed[0].state)).toMatchObject( action === 'raise' ? { gameState: { myTurn: false }, lastRaise: 3n } : { gameState: { myTurn: false }, lastRaise: 0n }, diff --git a/front-end/src/features/spacePoker/stateCodec.ts b/games/spacepoker/ui/stateCodec.ts similarity index 99% rename from front-end/src/features/spacePoker/stateCodec.ts rename to games/spacepoker/ui/stateCodec.ts index bd0a75e60..dbc8c9bfd 100644 --- a/front-end/src/features/spacePoker/stateCodec.ts +++ b/games/spacepoker/ui/stateCodec.ts @@ -1,4 +1,4 @@ -import { defineGameStateCodec } from '../../lib/session/gameStateCodec'; +import { defineGameStateCodec } from '../../host'; export type SpacepokerDisplayMode = 'xch' | 'mojos' | 'units'; export type SpHandler = 0n | 1n | 2n | 3n | 4n | 5n | 6n; 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..f6fe98269 --- /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 HandTermsModel, +} from '../../host'; +import { spacepokerStateCodec } from './stateCodec'; +function positive(value: bigint | undefined): bigint | null { + return value !== undefined && value > 0n ? value : null; +} + +export type SpacepokerTerms = HandTermsModel & { unitSizeMojos: bigint }; + +export function spacepokerTermsOf(terms: HandTermsModel): SpacepokerTerms | null { + if (terms.gameType !== 'spacepoker') return null; + const unitSizeMojos = (terms as SpacepokerTerms).unitSizeMojos; + return positive(unitSizeMojos) ? (terms 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?: HandTermsModel | 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/front-end/src/features/spacePoker/useSpacepokerHand.ts b/games/spacepoker/ui/useSpacepokerHand.ts similarity index 97% rename from front-end/src/features/spacePoker/useSpacepokerHand.ts rename to games/spacepoker/ui/useSpacepokerHand.ts index 3ddf874cb..40b308345 100644 --- a/front-end/src/features/spacePoker/useSpacepokerHand.ts +++ b/games/spacepoker/ui/useSpacepokerHand.ts @@ -1,14 +1,16 @@ import { useState, useEffect, useCallback, useRef, useMemo } 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 type { PersistedGameState } from '../../lib/session/gameStateCodec'; -import type { GameTerminalModel } from '../../lib/session/types'; -import type { StateUpdate } from '../../lib/gameAdapter'; -import type { LocalGameCommand } from '../../lib/session/sessionMachineTypes'; -import { type SettlementOutcome } from '../../lib/settlement'; +import { GameplayEvent } from '../../host'; +import { requireLiveGameHandSource, type GameHandSource } from '../../host'; +import { useGameHost } from '../../host/ui'; +import type { + PersistedGameState, + GameTerminalModel, + StateUpdate, + LocalGameCommand, + SettlementOutcome, +} from '../../host'; import { reduceSpacepokerFeatureState, reduceSpacepokerSettlementState } from './adapter'; import { spacepokerStateCodec, @@ -175,13 +177,13 @@ function defaultDisplayModeForUnit(unitSizeMojos: bigint): SpacepokerDisplayMode return unitSizeMojos > SPACEPOKER_XCH_DISPLAY_THRESHOLD_MOJOS ? 'xch' : 'mojos'; } -function formatXch(mojos: bigint): string { +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} ${getCurrencyLabels().xch}`; + return `${sign}${frac ? `${whole}.${frac}` : whole} ${xchLabel}`; } export function rollbackOptimisticTerminalHistory( @@ -229,6 +231,7 @@ export function useSpacepokerHand( terminal: GameTerminalModel, initialPersistedState?: Readonly, ): UseSpacepokerHandResult { + const { currencyLabels } = useGameHost(); const interactive = handSource.interactionMode === 'live'; if (unitSizeMojos <= 0n) { throw new Error('Space Poker requires a positive unit size'); @@ -858,10 +861,10 @@ export function useSpacepokerHand( (units: bigint): string => { if (displayMode === 'units') return String(units); const mojos = units * betUnit; - if (displayMode === 'mojos') return `${mojos.toLocaleString()} ${getCurrencyLabels().mojos}`; - return formatXch(mojos); + if (displayMode === 'mojos') return `${mojos.toLocaleString()} ${currencyLabels.mojos}`; + return formatXch(mojos, currencyLabels.xch); }, - [betUnit, displayMode], + [betUnit, currencyLabels, displayMode], ); return { 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/common/types/game_type.rs b/src/common/types/game_type.rs index ec5b87ebf..f68690007 100644 --- a/src/common/types/game_type.rs +++ b/src/common/types/game_type.rs @@ -1,13 +1,47 @@ 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 factory's first-member +/// `initial_validation_program_hash` derived from a canonical probe. +/// +/// 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 +52,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/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..3e608b8ad 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,134 @@ 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 `ui/package.ts`. +#[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) && !root.join("ui/package.ts").is_file() { + missing_files.push(format!("games/{key}/ui/package.ts")); + } + } + 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..5cf543663 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, @@ -248,6 +248,7 @@ pub enum GameNotification { initial_validation_program_hash: Hash, initial_state: ProgramRef, game_type: GameType, + parameters: Program, }, ProposalAccepted { id: GameID, 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/mod.rs b/src/session_phases/mod.rs index d876237dd..d83804e8e 100644 --- a/src/session_phases/mod.rs +++ b/src/session_phases/mod.rs @@ -13,8 +13,8 @@ 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, @@ -43,7 +43,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( @@ -143,7 +143,7 @@ fn format_batch_action(action: &BatchAction) -> String { 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 +238,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 +252,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 +322,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, @@ -735,6 +757,7 @@ impl OffChainPhase { initial_validation_program_hash: ivp_hash, initial_state, game_type: resolved_game_type, + parameters: wire.start.parameters.clone(), })); } } @@ -1937,7 +1960,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/simulator/mod.rs b/src/simulator/mod.rs index 2121cee5e..79eeca510 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, @@ -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..05752c2be 100644 --- a/src/simulator/tests/session_phases_sim.rs +++ b/src/simulator/tests/session_phases_sim.rs @@ -12,8 +12,8 @@ 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::{ @@ -914,7 +914,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 +1002,7 @@ fn run_game_container_with_action_list_with_success_predicate( allocator, rng, identities, - game_type, + package_key, extras, moves_input, pred, @@ -1343,7 +1343,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 +1395,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 +1430,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 +1896,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 +2621,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 +2703,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 +2788,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 +2878,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 +2959,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 +2984,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 +3011,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 +3062,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 +3105,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, @@ -4393,7 +4397,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 +4826,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 +4896,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 +5862,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 +5947,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 +6045,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 +6136,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 +6219,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 +6278,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 +6324,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 +6377,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 +6669,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..f29ba95ca 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, }; @@ -572,12 +570,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..17d9b5ada 100755 --- a/tools/build-chialisp.sh +++ b/tools/build-chialisp.sh @@ -10,19 +10,28 @@ STATE_FILE=".build-chialisp.state" CURRENT_STATE=$(mktemp) trap 'rm -f "$CURRENT_STATE"' EXIT +clsp_sources() { + { + find clsp games -type f \( -name '*.clsp' -o -name '*.clinc' \) -print + printf '%s\n' \ + build.rs Cargo.toml Cargo.lock chialisp.toml \ + games/registry.json \ + tools/build-chialisp.sh + } | LC_ALL=C sort +} + +clsp_hex() { + find clsp games -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 +48,14 @@ elif [ -f "$STATE_FILE" ] && cmp -s "$CURRENT_STATE" "$STATE_FILE"; then fi SECONDS=0 -find clsp -name '*.hex' -delete +find clsp games -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 clsp games -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/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/src/mod.rs b/wasm/src/mod.rs index be6db7f0a..dbb722546 100644 --- a/wasm/src/mod.rs +++ b/wasm/src/mod.rs @@ -138,7 +138,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 +221,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={})", @@ -805,7 +807,7 @@ mod gaming_wasm { #[derive(Deserialize)] struct JsGameProposal { - // Game name + // Factory first-validator hash, 32-byte hex. Not a catalog name. game_type: String, timeout: u64, } @@ -820,6 +822,53 @@ 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 factory-derived validator-hash `id`. + /// 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 +879,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, From e684a1f315c123ebc396d80dcb41493d6fa0377e Mon Sep 17 00:00:00 2001 From: Bram Cohen Date: Wed, 19 Aug 2026 21:23:30 -0700 Subject: [PATCH 02/12] Fix Chialisp build cache when games/ is missing. The regression harness is a stub repo with only clsp/; GNU find exits 1 if a named search root does not exist. --- tools/build-chialisp.sh | 24 +++++++++++++++++++----- tools/test-build-chialisp.sh | 3 ++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/tools/build-chialisp.sh b/tools/build-chialisp.sh index 17d9b5ada..66dde3ae5 100755 --- a/tools/build-chialisp.sh +++ b/tools/build-chialisp.sh @@ -10,18 +10,32 @@ 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 clsp games -type f \( -name '*.clsp' -o -name '*.clinc' \) -print - printf '%s\n' \ + 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 clsp games -type f -name '*.hex' -print | LC_ALL=C sort + find_chialisp -type f -name '*.hex' -print | LC_ALL=C sort } write_state() { @@ -48,14 +62,14 @@ elif [ -f "$STATE_FILE" ] && cmp -s "$CURRENT_STATE" "$STATE_FILE"; then fi SECONDS=0 -find clsp games -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 -if ! { find clsp games -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/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 From 8bad9aa23f711d9eab0ac2516b799aa676a75bf4 Mon Sep 17 00:00:00 2001 From: Bram Cohen Date: Fri, 21 Aug 2026 06:01:42 -0700 Subject: [PATCH 03/12] Give game packages a generated hand proposal contract. Separate proposal composition, durable state, and play mounts so game UIs receive only accepted-hand data and host-owned lifecycle handling. --- FRONTEND_ARCHITECTURE.md | 72 ++-- GAME_WRITING_GUIDE.md | 318 ++++++++++++++++ HANDLER_GUIDE.md | 21 +- OVERVIEW.md | 225 +----------- README.md | 20 +- UX_NOTIFICATIONS.md | 15 +- front-end/package.json | 8 +- front-end/scripts/generate-game-registry.mjs | 16 +- .../src/components/GameProposalDialogs.tsx | 10 +- front-end/src/components/GameSession.tsx | 11 + front-end/src/generated/gamePackages.ts | 15 +- front-end/src/hooks/useGameSession.ts | 36 +- front-end/src/lib/gameMountRegistry.tsx | 39 +- front-end/src/lib/gameProposalCodec.ts | 75 ++++ front-end/src/lib/gameRegistry.ts | 84 ++--- front-end/src/lib/gameTabAttention.ts | 13 +- front-end/src/lib/session/composeDraft.ts | 32 +- .../src/lib/session/gameSessionEvents.ts | 159 +------- front-end/src/lib/session/incomingProposal.ts | 19 + front-end/src/lib/session/normalization.ts | 6 +- front-end/src/lib/session/persistence.ts | 96 ++--- .../lib/session/persistenceBetweenHands.ts | 28 +- front-end/src/lib/session/saveEnvelope.ts | 12 +- front-end/src/lib/session/selectors.ts | 2 +- .../lib/session/sessionMachineBetweenHands.ts | 6 +- .../src/lib/session/sessionMachineCommands.ts | 35 +- .../src/lib/session/sessionMachineGame.ts | 18 +- .../lib/session/sessionMachineInterpreter.ts | 21 +- .../session/sessionMachineNotifications.ts | 86 ++--- .../lib/session/sessionMachineProposals.ts | 16 +- .../src/lib/session/sessionMachineRuntime.ts | 2 +- .../src/lib/session/sessionMachineTypes.ts | 19 +- front-end/src/lib/session/sessionResult.ts | 15 +- front-end/src/lib/session/sessionSnapshot.ts | 49 +-- front-end/src/lib/session/types.ts | 14 +- .../tests/finished_session_game_view.test.ts | 2 +- front-end/src/lib/tests/game_adapters.test.ts | 155 +++++--- .../lib/tests/game_feature_reducers.test.ts | 21 +- .../src/lib/tests/game_mount_registry.test.ts | 88 +++-- .../lib/tests/game_package_isolation.test.ts | 5 +- front-end/src/lib/tests/game_slice.test.ts | 4 +- .../src/lib/tests/game_state_codecs.test.ts | 6 +- .../src/lib/tests/game_tab_attention.test.ts | 16 +- .../load_wasm.calpoker_completion.test.ts | 12 +- .../lib/tests/load_wasm.game_restore.test.ts | 96 ++--- front-end/src/lib/tests/load_wasm.harness.ts | 20 +- .../tests/load_wasm.krunk_completion.test.ts | 10 +- .../tests/message_protocol.transport.test.ts | 2 +- front-end/src/lib/tests/save.harness.ts | 2 +- front-end/src/lib/tests/save.state.test.ts | 2 +- .../lib/tests/session_machine.compose.test.ts | 10 +- .../session_machine.feature_state.test.ts | 24 +- .../src/lib/tests/session_machine.harness.ts | 6 +- .../lib/tests/session_machine.krunk.test.ts | 2 +- .../tests/session_machine.proposals.test.ts | 20 +- .../tests/session_machine_interpreter.test.ts | 56 +-- .../lib/tests/session_model.proposals.test.ts | 34 +- .../lib/tests/session_model.restore.test.ts | 47 ++- .../lib/tests/session_model_roundtrip.test.ts | 22 +- .../session_save_envelope.boundary.test.ts | 4 +- .../tests/session_save_envelope.fixtures.ts | 16 +- .../session_save_envelope.roundtrip.test.ts | 26 +- .../session_save_envelope.validation.test.ts | 50 +-- .../lib/tests/terminal_finalization.test.ts | 8 +- .../lib/tests/terminal_game_controls.test.tsx | 4 +- .../src/lib/tests/terminal_model.test.ts | 6 +- .../lib/tests/wasm_gameplay_events.test.ts | 145 ++++++++ front-end/src/lib/wasm/gameplayEvents.ts | 81 +++++ front-end/src/lib/wasm/parseAmount.ts | 7 + front-end/src/types/ChiaGaming.ts | 14 +- front-end/tsconfig.json | 1 + games/calpoker/ui/{index.tsx => Calpoker.tsx} | 0 games/calpoker/ui/calPoker.test.ts | 2 +- games/calpoker/ui/handProposal.ts | 115 ++++++ ...ComposeEditor.tsx => handProposalForm.tsx} | 6 +- games/calpoker/ui/package.ts | 17 - games/calpoker/ui/{LiveMount.tsx => play.tsx} | 24 +- .../calpoker/ui/{adapter.ts => serialize.ts} | 178 ++++----- games/calpoker/ui/stateCodec.ts | 78 ---- games/calpoker/ui/useCalpokerHand.ts | 6 +- games/host/index.ts | 47 +-- games/krunk/ui/Krunk.tsx | 43 +-- games/krunk/ui/handProposal.ts | 114 ++++++ ...ComposeEditor.tsx => handProposalForm.tsx} | 8 +- games/krunk/ui/index.ts | 2 - games/krunk/ui/krunk.test.ts | 129 ++----- games/krunk/ui/package.ts | 17 - games/krunk/ui/{LiveMount.tsx => play.tsx} | 9 +- games/krunk/ui/{adapter.ts => serialize.ts} | 290 +++++++++------ games/krunk/ui/stateCodec.ts | 184 ---------- games/krunk/ui/useKrunkHand.ts | 4 +- games/spacepoker/ui/handProposal.ts | 131 +++++++ ...ComposeEditor.tsx => handProposalForm.tsx} | 6 +- games/spacepoker/ui/index.ts | 2 - games/spacepoker/ui/package.ts | 18 - .../spacepoker/ui/{LiveMount.tsx => play.tsx} | 50 +-- .../ui/{adapter.ts => serialize.ts} | 344 +++++++++++------- games/spacepoker/ui/spacePoker.test.ts | 37 +- games/spacepoker/ui/stateCodec.ts | 216 ----------- games/spacepoker/ui/unitSize.ts | 16 +- games/spacepoker/ui/useSpacepokerHand.ts | 6 +- 101 files changed, 2524 insertions(+), 2212 deletions(-) create mode 100644 GAME_WRITING_GUIDE.md create mode 100644 front-end/src/lib/gameProposalCodec.ts create mode 100644 front-end/src/lib/session/incomingProposal.ts create mode 100644 front-end/src/lib/tests/wasm_gameplay_events.test.ts create mode 100644 front-end/src/lib/wasm/gameplayEvents.ts create mode 100644 front-end/src/lib/wasm/parseAmount.ts rename games/calpoker/ui/{index.tsx => Calpoker.tsx} (100%) create mode 100644 games/calpoker/ui/handProposal.ts rename games/calpoker/ui/{ComposeEditor.tsx => handProposalForm.tsx} (81%) delete mode 100644 games/calpoker/ui/package.ts rename games/calpoker/ui/{LiveMount.tsx => play.tsx} (89%) rename games/calpoker/ui/{adapter.ts => serialize.ts} (60%) delete mode 100644 games/calpoker/ui/stateCodec.ts create mode 100644 games/krunk/ui/handProposal.ts rename games/krunk/ui/{ComposeEditor.tsx => handProposalForm.tsx} (83%) delete mode 100644 games/krunk/ui/index.ts delete mode 100644 games/krunk/ui/package.ts rename games/krunk/ui/{LiveMount.tsx => play.tsx} (87%) rename games/krunk/ui/{adapter.ts => serialize.ts} (50%) delete mode 100644 games/krunk/ui/stateCodec.ts create mode 100644 games/spacepoker/ui/handProposal.ts rename games/spacepoker/ui/{ComposeEditor.tsx => handProposalForm.tsx} (91%) delete mode 100644 games/spacepoker/ui/index.ts delete mode 100644 games/spacepoker/ui/package.ts rename games/spacepoker/ui/{LiveMount.tsx => play.tsx} (71%) rename games/spacepoker/ui/{adapter.ts => serialize.ts} (58%) delete mode 100644 games/spacepoker/ui/stateCodec.ts diff --git a/FRONTEND_ARCHITECTURE.md b/FRONTEND_ARCHITECTURE.md index 0c925aa46..2cc09d583 100644 --- a/FRONTEND_ARCHITECTURE.md +++ b/FRONTEND_ARCHITECTURE.md @@ -496,10 +496,10 @@ are grouped under those phase-owned payloads: | `cleanShutdownStarted` | `boolean` | Whether clean shutdown has been requested. | | `betweenHandMode` | `string` | Between-hand overlay state. | | `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`. | -| `betweenHandLastTerms` | `SavedHandTerms \| null` | Last agreed hand terms, independent of the compose draft. Null when there is no agreed hand yet. | -| `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. | +| `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. | @@ -555,7 +555,7 @@ 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 @@ -658,30 +658,22 @@ 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. Each production game exports one `GamePackage` from -`games//ui/package.ts`. That package owns display metadata, the compose -editor, the state codec, the factory-parameter codec (`factoryParameters` -encode/decode of that game's canonical factory blob), `describeTerms` for the -incoming-proposal dialog, plus `toFactoryParameters` / `decodeProposalTerms`, -term validation/equality, persisted extras, membership -rules, and live/frozen mounts. Game UI imports only from `games/host` (the -portable host contract) plus `react` / `rxjs` / `clvm-lib`. It does not import -this player app. Another implementation can copy `games/` and satisfy the same -contract. `games/registry.json` is the only catalog. -Factory hashes live in `gameIdentities.ts` (warmup fills the table; Active -completes leftover probes). The JS session model and saves store catalog keys -(`calpoker`, `spacepoker`, `krunk`). `packageFor` accepts those keys only. -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. +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. Factory hashes live in +`gameIdentities.ts` (warmup fills the table; Active completes leftover probes). +The JS session model and saves store catalog keys (`calpoker`, `spacepoker`, +`krunk`). `packageFor` accepts those keys only. 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` @@ -1399,7 +1391,9 @@ The cohesive session modules own those responsibilities: - `sessionMachineInterpreter.ts` performs controller calls, timers, persistence, gameplay emission, 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/wasm/gameplayEvents.ts` projects typed WASM payloads onto host `GameplayEvent` values. +- `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 @@ -1416,7 +1410,10 @@ The active game UI is rendered inside `GameSession` from the selected key only. `front-end/src/lib/gameMountRegistry.tsx` dispatches live/frozen mounts through that package. Factory hashes are protocol ids at the WASM propose/notify boundary (`protocolIdForCatalog` out, `catalogGameTypeFromWire` -in). +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`. `CalpokerHand` receives gameplay events via an RxJS observable and submits moves through the shared Rust-first local-action boundary. @@ -1449,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: @@ -1508,14 +1506,14 @@ 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`; it is not a `GameplayEvent` ### Gameplay events (forwarded to game UI via observable) These are the normal flow of play, forwarded to the active game UI component via the `gameplayEventSubject` RxJS stream: -- `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) @@ -1610,6 +1608,8 @@ not to limit concurrency. | `front-end/src/lib/session/persistence*.ts` | Canonical strict-v14 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` → v14 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/wasm/gameplayEvents.ts` | WASM notification/event payloads → host `GameplayEvent` | | `front-end/src/lib/gameMountRegistry.tsx` | Live/frozen mounts 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 | diff --git a/GAME_WRITING_GUIDE.md b/GAME_WRITING_GUIDE.md new file mode 100644 index 000000000..b91421661 --- /dev/null +++ b/GAME_WRITING_GUIDE.md @@ -0,0 +1,318 @@ +# 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`. + +## 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 a hash derived from the compiled factory. Peers use it + to identify the game on the wire. + +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. + +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. The build uses it to calculate the protocol ID. + +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) { + // ... +} +``` + +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)` reconstructs and validates the proposal + received from the peer. + +The host provides `readClvmProgram`, `readClvmAtom`, `readClvmFlag`, and +`readClvmList` to help write strict decoders. + +## 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 `durableState.reduceEvent`. This reducer updates the saved UI +state when the host reports: + +- `accepted-group`: the proposal was accepted and the hand started. +- `game-status`: the protocol state or readable game data changed. +- `local-turn`: the local turn flag changed. +- `settled`: the game finished. +- `remove-group`: this group was removed. +- `abandoned`: the session was abandoned. +- `feature-state`: the game UI committed one of its own state changes. + +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 +two rendering functions: + +- `renderLive(session, names)` renders a hand that can still send commands. +- `renderFrozen(view, options)` renders a finished or restored hand without + allowing protocol commands. + +The live view provides active game IDs, accepted game amounts, gameplay events, +turn callbacks, durable game state, and display names. The frozen view provides +the same accepted-game information in read-only form with final results. +Neither view receives the `HandProposal`: use the `accepted-group` reducer to +copy any game-specific proposal settings into durable state when the hand +starts. + +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 sending a command. This prevents a +finished or historical view from accidentally acting on the live protocol. +Use `terminalGameHandSource` when constructing a read-only source. + +The main command boundary is `commitLocalGameAction`. Submit one of these +`LocalGameCommand` values: + +- `make-move` for a normal game action. +- `accept-settlement` when the player agrees to finish the game. +- `cheat` only for game-specific testing or deliberate cheat controls. + +The host sends normalized `GameplayEvent` values to the UI: + +- `OpponentMoved` contains readable data from an opponent's move. +- `GameMessage` contains an informational game message. +- `MoveRejected` explains why a local move was rejected. +- `Settled` reports the final settlement. +- `GameError` reports a failed action or terminal operation. + +These events are intentionally independent of the raw WASM notification +format. A game should not import frontend session code to interpret WASM +messages. + +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`, `rxjs`, 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`, `ActionFailed`, and + `ProposalMade` +- [`front-end/src/lib/wasm/gameplayEvents.ts`](front-end/src/lib/wasm/gameplayEvents.ts) +- [`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.reduceEvent` handles starting, playing, settling, removing, and + restoring a hand. +- Live and frozen views render the expected game state. +- 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 0530ae83c..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 @@ -38,16 +39,10 @@ Games are driven by two cooperating systems: are chialisp programs, curried with game-specific state. - **Validators** enforce the rules of each move. They are chialisp programs, - run both off-chain (to check a move before sending it) and on-chain (to - settle disputes). - -Each game is a package under `games//` with `clsp/factory.clsp`, -`rust/mod.rs` (prepared factory + probe), `rust/tests/mod.rs`, and for -production games `ui/package.ts`. Register the key in `games/registry.json`; -do not hand-edit factory catalogs or frontend import lists. 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 diff --git a/OVERVIEW.md b/OVERVIEW.md index 0f914715d..63a12d808 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -497,20 +497,15 @@ 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 repository includes three production reference games: + +- **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`. Each game lives in one top-level package under `games//`, registered only in [`games/registry.json`](games/registry.json) (`production` vs `test`). Package @@ -519,214 +514,13 @@ first-member `initial_validation_program_hash` from a canonical probe — never the human-readable key. 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. +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). The Rust game collection also registers `debug` (test list) 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. - -### Commit-Reveal Protocol - -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 -``` - -**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` -- `games/calpoker/clsp/onchain/a.clsp` through `e.clsp` -- `games/calpoker/clsp/calpoker_generate.clinc` — off-chain handlers -- `games/calpoker/rust/tests/sim.rs` — Rust-side calpoker registration/helpers -- `games/spacepoker/clsp/onchain/*.clsp` -- `games/spacepoker/clsp/spacepoker_generate.clinc` — Space Poker handlers -- `games/spacepoker/rust/tests/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 `games/krunk/clsp/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/` and `games/` 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:** - -- `games/krunk/clsp/krunk_generate.clinc` — off-chain handlers (Alice/Bob) -- `games/krunk/clsp/onchain/{commit,guess,clue}.clsp` — on-chain validators -- `games/krunk/clsp/krunk_helpers.clinc` — clue encoding, payout tables -- `games/krunk/clsp/krunk_signed_dict_tree.dat` — generated: 48-byte pubkey + signed tree (binary) -- `games/krunk/rust/` — 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 - --- ## Handler Architecture @@ -854,7 +648,7 @@ Shared utilities used by multiple handlers (e.g. `build_channel_to_unroll_bundle | `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 (see [Krunk](#krunk)) | +| `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`) | @@ -916,6 +710,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..1f6ffd53c 100644 --- a/UX_NOTIFICATIONS.md +++ b/UX_NOTIFICATIONS.md @@ -692,10 +692,11 @@ These are not lifecycle invariants but important rules enforced in the code: ## GameplayEvent 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: +`front-end/src/lib/wasm/gameplayEvents.ts` translates typed WASM payloads into +game-agnostic `GameplayEvent` variants. The session notification reducer and +`useGameSession` call that adapter, then forward host events to game hooks +(`useCalpokerHand`, `useSpacepokerHand`, `useKrunkHand`). Game hooks never see +raw notifications; they receive one of: | Variant | Shape | When | | ------------------ | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -704,7 +705,7 @@ Game hooks never see raw notifications; they receive one of: | `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 | +| `GameError` | `{ gameId, reason, source, action? }` | `EndedCancelled`, `EndedError`, unknown settlement, scoped `ActionFailed`, or JS `game-action-error` | Settlement label helpers live in `front-end/src/lib/settlement.ts` (`settlementLabel`, `isForfeitOutcome`, game-specific copy helpers). @@ -713,6 +714,6 @@ 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/wasm/gameplayEvents.ts`, +`front-end/src/lib/session/gameSessionEvents.ts` (`terminalInfoFromGameSettled`), `front-end/src/lib/settlement.ts` diff --git a/front-end/package.json b/front-end/package.json index 766897221..e17d71f81 100644 --- a/front-end/package.json +++ b/front-end/package.json @@ -54,11 +54,17 @@ "setupFilesAfterEnv": [ "/scripts/testSetup.ts" ], + "roots": [ + "/src", + "/../games" + ], "testMatch": [ "/src/**/*.{spec,test}.{js,jsx,ts,tsx}", "/../games/*/ui/**/*.{spec,test}.{ts,tsx}" ], - "testPathIgnorePatterns": [], + "testPathIgnorePatterns": [ + "/src/features/" + ], "moduleDirectories": [ "node_modules", "node-pkg", diff --git a/front-end/scripts/generate-game-registry.mjs b/front-end/scripts/generate-game-registry.mjs index 8151e1b7d..7822cf1f8 100644 --- a/front-end/scripts/generate-game-registry.mjs +++ b/front-end/scripts/generate-game-registry.mjs @@ -28,12 +28,20 @@ function extraPresets(key) { } 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) => { - const rel = relative(join(FE, '../src/generated'), join(ROOT, 'games', key, 'ui/package.ts')) - .replace(/\\/g, '/') - .replace(/\.ts$/, ''); - return `import pkg${index} from '${rel.startsWith('.') ? rel : `./${rel}`}';`; + 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} = Object.assign({}, handProposal${index}, { HandProposalForm: HandProposalForm${index}, ...play${index} });`, + ].join('\n'); }) .join('\n'); const packageList = production.map((_, index) => `pkg${index}`).join(', '); diff --git a/front-end/src/components/GameProposalDialogs.tsx b/front-end/src/components/GameProposalDialogs.tsx index 7cf4bde81..abc77c709 100644 --- a/front-end/src/components/GameProposalDialogs.tsx +++ b/front-end/src/components/GameProposalDialogs.tsx @@ -19,7 +19,7 @@ export function ComposeProposalDialog({ const compose = session.composeDraftState; const pkg = packageFor(compose.selectedGame); const canSubmit = composeDraftCanSubmit(compose, maxPerHandMojos); - const Editor = pkg.ComposeEditor; + const Editor = pkg.HandProposalForm; const submit = () => { if (!canSubmit) return; @@ -91,10 +91,12 @@ export function ReviewProposalDialog({ session }: { session: UseGameSessionResul

Do you want to accept this hand?

-

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

-

{describeReceivedProposal(review.terms)}

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

+

{describeReceivedProposal(review.handProposal)}

+

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

-
- )} 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, }, @@ -123,7 +127,13 @@ const registration: GameFeatureRegistration< }, }, durableState: { - reduceEvent: reduceSpacepokerDurableState, + initialize(current, input) { + return reduceSpacepokerDurableState(current, input)!; + }, + reduceInput(current, input) { + return reduceSpacepokerDurableState(current, input)!; + }, + applyFeatureState: (_current, _gameId, state) => state, }, }; diff --git a/games/spacepoker/ui/play.tsx b/games/spacepoker/ui/play.tsx index 5527d494c..91d7f8d93 100644 --- a/games/spacepoker/ui/play.tsx +++ b/games/spacepoker/ui/play.tsx @@ -1,13 +1,10 @@ import { lazy, useCallback } from 'react'; -import { EMPTY, type Observable } from 'rxjs'; import { EMPTY_GAME_TERMINAL_MODEL, gameHandState, - terminalGameHandSource, - type FrozenGameView, + gameHandSourceFromMountView, type GameHandSource, type GameMountRegistration, - type GameplayEvent, type GameTerminalModel, } from '../../host'; import { useGameHost } from '../../host/ui'; @@ -15,22 +12,11 @@ import { spacepokerStateCodec } from './serialize'; const SpacePoker = lazy(() => import('./SpacePoker')); -function amountForGame(amountsById: Record, gameId: string): bigint { - const amount = amountsById[gameId]; - if (amount === undefined) { - throw new Error(`Space Poker is missing the accepted amount for game ${gameId}`); - } - return BigInt(amount); -} - export interface SpacepokerLiveMountProps { handSource: GameHandSource; gameId: string; - iStarted: boolean; - gameplayEvent$: Observable; betSize: bigint; - onTurnChanged: (gameId: string, isMyTurn: boolean) => void; - appendGameLog: (line: string) => void; + appendGameLog?: (line: string) => void; myName?: string; opponentName?: string; terminal: GameTerminalModel; @@ -40,10 +26,7 @@ export function SpacepokerLiveMount(props: SpacepokerLiveMountProps) { const { handSource, gameId, - iStarted, - gameplayEvent$, betSize, - onTurnChanged, appendGameLog, myName, opponentName, @@ -56,12 +39,9 @@ export function SpacepokerLiveMount(props: SpacepokerLiveMountProps) { } const unitSizeMojosValue = handState.unitSizeMojos; const stackSize = betSize / unitSizeMojosValue; - const handleTurnChanged = useCallback( - (isMyTurn: boolean) => onTurnChanged(gameId, isMyTurn), - [gameId, onTurnChanged], - ); const handleGameLog = useCallback( (lines: string[]) => { + if (!appendGameLog) return; appendGameLog(`Space Poker ${stackSize} (${formatAmount(unitSizeMojosValue)})`); lines.forEach(appendGameLog); appendGameLog(''); @@ -73,11 +53,8 @@ export function SpacepokerLiveMount(props: SpacepokerLiveMountProps) { - ); - }, - renderFrozen(view: FrozenGameView, options) { - const gameId = view.lastDisplayedId ?? view.currentHandIds[0] ?? view.activeIds[0] ?? 'finished'; + 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 ( {}} - appendGameLog={() => {}} + appendGameLog={view.frozen ? undefined : view.appendGameLog} terminal={view.instances[gameId]?.terminal ?? EMPTY_GAME_TERMINAL_MODEL} - myName={options.myName} - opponentName={options.opponentName} + myName={view.myName} + opponentName={view.opponentName} /> ); }, diff --git a/games/spacepoker/ui/serialize.ts b/games/spacepoker/ui/serialize.ts index 60dfbc5f5..301699755 100644 --- a/games/spacepoker/ui/serialize.ts +++ b/games/spacepoker/ui/serialize.ts @@ -2,7 +2,7 @@ import { Program } from 'clvm-lib'; import { defineGameStateCodec, isForfeitOutcome, - type DurableGameStateEvent, + type GameInput, type SettlementOutcome, } from '../../host'; @@ -35,12 +35,6 @@ export type SpTerminalState = | 'folded-by-you' | 'folded-by-opponent' | 'won-by-opponent-failure'; -export interface PendingSpacepokerTerminalAction { - action: 'fold' | 'concede' | 'reveal'; - submission: 'make-move' | 'accept-settlement'; - previousTerminalState: SpTerminalState; - previousGameState: SpGameState; -} export interface SpacepokerHandState { gameState: SpGameState; @@ -54,9 +48,7 @@ export interface SpacepokerHandState { iRaisedLast: boolean; handHistory: SpHandEntry[]; outcome: SpOutcome | null; - terminalState?: SpTerminalState; - terminalRecovery?: 'concede' | 'reveal' | null; - pendingTerminalAction: PendingSpacepokerTerminalAction | null; + terminalState: SpTerminalState; coinTossIOpen: boolean | null; unitSizeMojos: bigint; displayMode: SpacepokerDisplayMode; @@ -145,18 +137,6 @@ function isOutcome(value: unknown): value is SpOutcome { ); } -function isPendingTerminalAction(value: unknown): value is PendingSpacepokerTerminalAction { - if (typeof value !== 'object' || value === null) return false; - const pending = value as Partial; - return ( - (pending.action === 'fold' || pending.action === 'concede' || pending.action === 'reveal') && - (pending.submission === 'make-move' || pending.submission === 'accept-settlement') && - typeof pending.previousTerminalState === 'string' && - TERMINALS.has(pending.previousTerminalState) && - isGameState(pending.previousGameState) - ); -} - function isSpacepokerHandState(value: unknown): value is SpacepokerHandState { if (typeof value !== 'object' || value === null) return false; const state = value as Partial; @@ -185,12 +165,6 @@ function isSpacepokerHandState(value: unknown): value is SpacepokerHandState { (state.terminalState === 'won-by-opponent-failure' && state.gameState.handler === 6n); if (!terminalHandlerMatches) return false; if (state.terminalState === 'revealed' && state.outcome === null) return false; - if ( - state.terminalRecovery != null && - (state.terminalState !== 'none' || state.gameState.handler !== 4n) - ) { - return false; - } return ( typeof state.playerBoost === 'boolean' && (state.opponentBoost === null || typeof state.opponentBoost === 'boolean') && @@ -201,11 +175,6 @@ function isSpacepokerHandState(value: unknown): value is SpacepokerHandState { typeof state.iRaisedLast === 'boolean' && isHistory(state.handHistory) && (state.outcome === null || isOutcome(state.outcome)) && - (state.terminalRecovery === null || - state.terminalRecovery === 'concede' || - state.terminalRecovery === 'reveal') && - (state.pendingTerminalAction === null || - isPendingTerminalAction(state.pendingTerminalAction)) && (state.coinTossIOpen === null || typeof state.coinTossIOpen === 'boolean') && typeof state.unitSizeMojos === 'bigint' && state.unitSizeMojos > 0n && @@ -216,7 +185,7 @@ function isSpacepokerHandState(value: unknown): value is SpacepokerHandState { export const spacepokerStateCodec = defineGameStateCodec({ gameType: 'spacepoker', - version: 2n, + version: 3n, canRemountFinished: true, isState: isSpacepokerHandState, }); @@ -235,8 +204,6 @@ 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', @@ -295,7 +262,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') { @@ -305,7 +271,6 @@ function reduceSpacepokerSettlementStateCore( ...current, gameState: { handler: 5n, myTurn: false, N: 1n }, terminalState: 'conceded-by-opponent', - terminalRecovery: null, }, { player: 'opponent', action: 'concede' }, ); @@ -319,7 +284,6 @@ function reduceSpacepokerSettlementStateCore( N: current.gameState.N >= 1n ? current.gameState.N : 1n, }, terminalState: 'won-by-opponent-failure', - terminalRecovery: null, }, { player: 'opponent', action: 'failed' }, ); @@ -328,14 +292,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)) { @@ -356,7 +318,6 @@ function reduceSpacepokerSettlementStateCore( : player === 'you' ? 'conceded-by-you' : 'conceded-by-opponent', - terminalRecovery: null, }, { player, action }, ); @@ -370,7 +331,6 @@ function reduceSpacepokerSettlementStateCore( }, outcome: null, terminalState: 'settled', - terminalRecovery: null, }; } @@ -378,10 +338,7 @@ export function reduceSpacepokerSettlementState( current: SpacepokerHandState, outcome: SettlementOutcome, ): SpacepokerHandState { - return { - ...reduceSpacepokerSettlementStateCore(current, outcome), - pendingTerminalAction: null, - }; + return reduceSpacepokerSettlementStateCore(current, outcome); } function bigints(program: Program): bigint[] { @@ -550,7 +507,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' }, ); @@ -560,45 +516,27 @@ 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.handProposal.gameType !== 'spacepoker') return current; + if (event.type === 'hand-started') { + if (event.init.handProposal.gameType !== 'spacepoker') return current; const unitSizeMojos = - 'unitSizeMojos' in event.handProposal && typeof event.handProposal.unitSizeMojos === 'bigint' - ? event.handProposal.unitSizeMojos + 'unitSizeMojos' in event.init.handProposal && + typeof event.init.handProposal.unitSizeMojos === 'bigint' + ? event.init.handProposal.unitSizeMojos : 1n; - return current ?? initialState(event.isMyTurn, 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; + 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) { - return { - ...current, - gameState: { ...current.gameState, myTurn: event.status === 'my-turn' }, - }; - } + 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); } diff --git a/games/spacepoker/ui/spacePoker.test.ts b/games/spacepoker/ui/spacePoker.test.ts index a8cd01f7e..5a0fa4d99 100644 --- a/games/spacepoker/ui/spacePoker.test.ts +++ b/games/spacepoker/ui/spacePoker.test.ts @@ -1,23 +1,16 @@ import React from 'react'; import { act, create, type ReactTestRenderer } from 'react-test-renderer'; -import { EMPTY, Subject } from 'rxjs'; -import SpacePoker from './SpacePoker'; import { - isTerminalSpacepokerHandler, - opponentTerminalAction, - pendingTerminalActionMatchesFailure, - reconcilePendingTerminalHistory, - retainsRevealedTerminalPresentation, - rollbackOptimisticTerminalHistory, - SpHandler, - terminalAutoSubmissionAllowed, - terminalRecoveryAfterOpponentMove, - retainsVoluntaryTerminalPresentation, - voluntarySpacepokerSettlementAction, - useSpacepokerHand, - type UseSpacepokerHandResult, -} from './useSpacepokerHand'; + 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, @@ -25,292 +18,137 @@ import { spacePokerTerminalCommentary, spacePokerTransitionCommentary, } from './statusPresentation'; -import { - EMPTY_GAME_TERMINAL_MODEL, - type GameHandSource, - type GameplayEvent, - type LiveGamePort, - type LocalGameActionRequest, - type PersistedGameState, -} from '../../host'; -import { spacepokerRegistration } from './handProposal'; 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', + ...overrides, + }; +} -function liveSource(port: LiveGamePort): GameHandSource { - const handState = (port as LiveGamePort & { handState?: PersistedGameState | null }).handState; - return { interactionMode: 'live', handState: handState ?? null, port }; +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', () => { + it('uses a single-character ten rank and recognizes terminal handlers', () => { expect(spacePokerRankLabel(10n)).toBe('T'); - }); - - it('attributes only actual opponent folds and no-reveal flags', () => { - expect(opponentTerminalAction({ handler: SpHandler.MidRound, myTurn: false, N: 2n })).toBe( - 'fold', - ); - expect(opponentTerminalAction({ handler: SpHandler.End, myTurn: false, N: 1n })).toBe( - 'concede', - ); - expect(opponentTerminalAction({ handler: SpHandler.End, myTurn: true, N: 1n })).toBeNull(); - expect( - opponentTerminalAction({ handler: SpHandler.Showdown, myTurn: false, N: 0n }), - ).toBeNull(); - }); - - it('removes only the failed optimistic terminal action', () => { - const history = [ - { player: 'opponent' as const, action: 'raise' as const, units: 2n }, - { player: 'you' as const, action: 'concede' as const }, - ]; - - expect(rollbackOptimisticTerminalHistory(history, 'concede')).toEqual([ - { player: 'opponent', action: 'raise', units: 2n }, - ]); - expect(rollbackOptimisticTerminalHistory(history, 'fold')).toEqual(history); - }); - - it('keeps eyes when clean settlement confirms a pending reveal', () => { - const history = [ - { player: 'you' as const, action: 'check' as const }, - { player: 'you' as const, action: 'reveal' as const }, - ]; - - expect(reconcilePendingTerminalHistory(history, 'reveal', 'settled_cleanly')).toEqual(history); - expect(reconcilePendingTerminalHistory(history, null, 'settled_cleanly')).toEqual(history); - expect(reconcilePendingTerminalHistory(history, 'reveal', 'attempt_to_move_failed')).toEqual([ - { player: 'you', action: 'check' }, - { player: 'you', action: 'failed' }, - ]); - }); - - it('recognizes terminal handlers', () => { expect(isTerminalSpacepokerHandler(SpHandler.Folded)).toBe(true); expect(isTerminalSpacepokerHandler(SpHandler.Showdown)).toBe(true); expect(isTerminalSpacepokerHandler(SpHandler.End)).toBe(false); }); - it('clears stale live-turn text when terminal commentary takes over', () => { - expect(spacePokerFooterStatus(SpHandler.End, 'Your turn')).toBe('Your turn'); + it('presents terminal outcomes without stale turn text', () => { expect(spacePokerFooterStatus(SpHandler.Showdown, 'Your turn')).toBe(''); - expect(spacePokerFooterStatus(SpHandler.Folded, 'Waiting for opponent…')).toBe(''); - }); - - it('describes non-betting transitions near the start and end of a hand', () => { - expect(spacePokerTransitionCommentary(SpHandler.CommitA, true)).toBe('Dealing cards…'); - expect(spacePokerTransitionCommentary(SpHandler.CommitB, false)).toBe('Dealing cards…'); - expect(spacePokerTransitionCommentary(SpHandler.End, true)).toBe('Finishing hand…'); expect(spacePokerTransitionCommentary(SpHandler.End, false)).toBe( 'Waiting for opponent to finish…', ); - }); - - it('uses one commentary field with a message for every terminal hand', () => { - expect(spacePokerTerminalCommentary('conceded-by-opponent', null, 'we_accepted')).toBe( - 'You revealed first and the opponent conceded.', - ); expect(spacePokerTerminalCommentary('revealed', 1n, 'settled_cleanly')).toBe( 'You won at showdown.', ); - expect(spacePokerTerminalCommentary('revealed', -1n, 'settled_cleanly')).toBe( - 'The opponent won at showdown.', - ); - expect(spacePokerTerminalCommentary('revealed', 0n, 'settled_cleanly')).toBe( - 'The showdown ended in a tie.', - ); expect(spacePokerTerminalCommentary('settled', null, 'opponent_timed_out')).toBe( 'Opponent timed out.', ); - expect(spacePokerTerminalCommentary('settled', null, null)).toBe('The hand ended.'); - }); - - it('shows a winner rather than fold/reveal iconography for an opponent action failure', () => { expect(spacePokerTerminalBanners('won-by-opponent-failure', null)).toEqual({ player: 'win', opponent: null, }); }); - it('maps only voluntary settlement outcomes to terminal poker actions', () => { - expect( - voluntarySpacepokerSettlementAction('accept_settlement', { - handler: SpHandler.MidRound, - myTurn: false, - N: 2n, - }), - ).toEqual({ player: 'opponent', action: 'fold' }); - expect( - voluntarySpacepokerSettlementAction('we_accepted', { - handler: SpHandler.End, - myTurn: false, - N: 1n, - }), - ).toEqual({ player: 'you', action: 'concede' }); - - for (const outcome of [ - 'settled_cleanly', - 'opponent_timed_out', - 'timed_out_waiting_for_our_move', - 'slashed_opponent', - 'opponent_slashed_us', - ] as const) { - expect( - voluntarySpacepokerSettlementAction(outcome, { - handler: SpHandler.MidRound, - myTurn: false, - N: 2n, - }), - ).toBeNull(); - } - }); - - it('models controller-to-hook synchronous terminal failure ordering', () => { - const localReveal = { - action: 'reveal' as const, - submission: 'make-move' as const, - previousTerminalState: 'none' as const, - previousGameState: { handler: SpHandler.End, myTurn: true, N: 1n }, - }; - - // A regular move error has no matching terminal intent, so the hook leaves - // the playable hand untouched. - expect(pendingTerminalActionMatchesFailure(null, 'make-move')).toBe(false); - // A controller error emitted synchronously by local reveal clears the - // pending intent before the submission callback may transition to Showdown. - expect(pendingTerminalActionMatchesFailure(localReveal, 'make-move')).toBe(true); - expect(pendingTerminalActionMatchesFailure(localReveal, 'accept-settlement')).toBe(false); - }); - - it('retains revealed UI only for voluntary settlement acknowledgement', () => { - const localReveal = { - action: 'reveal' as const, - submission: 'make-move' as const, - previousTerminalState: 'none' as const, - previousGameState: { handler: SpHandler.End, myTurn: true, N: 1n }, - }; - - // Successful local reveal settlement clears pending but preserves history. - expect(retainsRevealedTerminalPresentation(localReveal, 'none', 'accept_settlement')).toBe( - true, - ); - expect(retainsRevealedTerminalPresentation(null, 'revealed', 'we_accepted')).toBe(true); - expect(retainsRevealedTerminalPresentation(localReveal, 'revealed', 'opponent_timed_out')).toBe( - false, - ); - expect(retainsRevealedTerminalPresentation(localReveal, 'revealed', 'slashed_opponent')).toBe( - false, - ); - // A late action error cannot roll back after the acknowledgement cleared pending. - expect(pendingTerminalActionMatchesFailure(null, 'make-move')).toBe(false); - }); - - it('retains restored fold and concede UI only for voluntary settlement acknowledgement', () => { - for (const terminalState of [ - 'folded-by-you', - 'folded-by-opponent', - 'conceded-by-you', - 'conceded-by-opponent', - ] as const) { - expect(retainsVoluntaryTerminalPresentation(terminalState, 'accept_settlement')).toBe(true); - expect(retainsVoluntaryTerminalPresentation(terminalState, 'we_accepted')).toBe(true); - expect(retainsVoluntaryTerminalPresentation(terminalState, 'opponent_timed_out')).toBe(false); - expect(retainsVoluntaryTerminalPresentation(terminalState, 'slashed_opponent')).toBe(false); - } - }); - - it('keeps concede separate from a showdown reveal', () => { - expect( - voluntarySpacepokerSettlementAction('accept_settlement', { - handler: SpHandler.End, - myTurn: true, - N: 1n, - }), - ).toEqual({ player: 'you', action: 'concede' }); - }); - - it('blocks automatic retry until a user retry or authoritative update', () => { - expect(terminalAutoSubmissionAllowed('reveal')).toBe(false); - expect(terminalAutoSubmissionAllowed('concede')).toBe(false); - expect(terminalAutoSubmissionAllowed(null)).toBe(true); + 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('preserves terminal recovery across unrelated opponent moves', () => { - expect(terminalRecoveryAfterOpponentMove('reveal', false)).toBe('reveal'); - expect(terminalRecoveryAfterOpponentMove('concede', false)).toBe('concede'); - expect(terminalRecoveryAfterOpponentMove('reveal', true)).toBeNull(); + 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 feature-state authority', () => { +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(), - }, + value: { addEventListener: jest.fn(), removeEventListener: jest.fn() }, }); }); - afterEach(() => { if (renderer) act(() => renderer?.unmount()); renderer = null; }); - afterAll(() => { - Object.defineProperty(globalThis, 'window', { - configurable: true, - value: originalWindow, - }); + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); }); - it('does not project or submit when the session rejects a local action commit', () => { - const makeMove = jest.fn(); - const onTurnChanged = jest.fn(); - const controller = { - handState: spacepokerStateCodec.encode({ - 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', - terminalRecovery: null, - pendingTerminalAction: null, - coinTossIOpen: true, - unitSizeMojos: 10n, - displayMode: 'units', - }), + 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, - transitionFeatureState: () => false, - commitLocalGameAction: () => { + dispatch: (intent: GameIntent) => { + rejected = intent; throw new Error('check rejected'); }, - makeMove, } as unknown as LiveGamePort; let hand: UseSpacepokerHandResult | undefined; function Harness() { hand = useSpacepokerHand( - liveSource(controller), + liveSource(port, persisted), '7', - false, - EMPTY, 100n, 10n, - onTurnChanged, EMPTY_GAME_TERMINAL_MODEL, - liveSource(controller).handState ?? undefined, ); return null; } @@ -319,322 +157,109 @@ describe('Space Poker feature-state authority', () => { 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([]); - expect(makeMove).not.toHaveBeenCalled(); - expect(onTurnChanged).not.toHaveBeenCalled(); - }); - - it('surfaces an automatic command failure instead of swallowing it', () => { - const controller = { - handState: spacepokerStateCodec.encode({ - gameState: { handler: SpHandler.CommitA, myTurn: true, N: 4n }, - playerHoleCards: null, - playerBoost: false, - opponentHoleCards: null, - opponentBoost: null, - communityCards: [null, null, null, null, null], - halfPot: 1n, - lastRaise: 0n, - iRaisedLast: false, - handHistory: [], - outcome: null, - terminalState: 'none', - terminalRecovery: null, - pendingTerminalAction: null, - coinTossIOpen: null, - unitSizeMojos: 10n, - displayMode: 'units', - }), - isChannelReady: () => true, - commitLocalGameAction: () => { - throw new Error('autoplay rejected'); - }, - } as unknown as LiveGamePort; - - function Harness() { - useSpacepokerHand( - liveSource(controller), - '7', - false, - EMPTY, - 100n, - 10n, - () => {}, - EMPTY_GAME_TERMINAL_MODEL, - liveSource(controller).handState ?? undefined, - ); - return null; - } - - expect(() => - act(() => { - renderer = create(React.createElement(Harness)); - }), - ).toThrow('autoplay rejected'); }); - it('commits a fold and its terminal presentation as one codec-valid state', () => { - const acceptSettlement = jest.fn(); - const gameplayEvents = new Subject(); - const onTurnChanged = jest.fn(); - const transitions: unknown[] = []; - const controller = { - handState: spacepokerStateCodec.encode({ - 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', - terminalRecovery: null, - pendingTerminalAction: null, - coinTossIOpen: true, - unitSizeMojos: 10n, - displayMode: 'units', - }), + it('commits an accepted codec-valid fold candidate through the live port', () => { + let persisted = spacepokerStateCodec.encode(handState()); + const committed: GameIntent[] = []; + const port = { isChannelReady: () => true, - transitionFeatureState: (_gameType: string, _gameId: string, state: unknown) => { - transitions.push(state); - return spacepokerRegistration.decodeFeatureState( state) !== null; - }, - transitionFeatureStateWithLocalTurn: (_gameType: string, _gameId: string, state: unknown) => { - transitions.push(state); - return spacepokerRegistration.decodeFeatureState( state) !== null; + dispatch: (intent: GameIntent) => { + committed.push(intent); + persisted = spacepokerStateCodec.encode(intent.state); }, - commitLocalGameAction: (request: LocalGameActionRequest) => { - if (request.command.type !== 'accept-settlement') throw new Error('unexpected command'); - acceptSettlement(request.id); - transitions.push(request.state); - }, - acceptSettlement, } as unknown as LiveGamePort; let hand: UseSpacepokerHandResult | undefined; function Harness() { hand = useSpacepokerHand( - liveSource(controller), + liveSource(port, persisted), '7', - false, - gameplayEvents, 100n, 10n, - onTurnChanged, EMPTY_GAME_TERMINAL_MODEL, - liveSource(controller).handState ?? undefined, ); return null; } - act(() => { renderer = create(React.createElement(Harness)); }); - act(() => { - hand?.handleFold(); - }); + act(() => hand?.handleFold()); - expect(transitions).toHaveLength(1); - expect(spacepokerRegistration.decodeFeatureState( transitions[0])).toMatchObject({ + 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 }, - terminalState: 'folded-by-you', handHistory: [{ player: 'you', action: 'fold' }], - pendingTerminalAction: { - action: 'fold', - submission: 'accept-settlement', - previousTerminalState: 'none', - previousGameState: { handler: SpHandler.MidRound, myTurn: true, N: 3n }, - }, - }); - expect(acceptSettlement).toHaveBeenCalledWith('7'); - expect(onTurnChanged).not.toHaveBeenCalled(); - - act(() => { - gameplayEvents.next({ - GameError: { - gameId: '7', - action: 'accept-settlement', - reason: 'cannot accept', - source: 'action', - }, - }); - }); - - expect(transitions).toHaveLength(2); - expect(spacepokerRegistration.decodeFeatureState( transitions[1])).toMatchObject({ - gameState: { handler: SpHandler.MidRound, myTurn: true, N: 3n }, - terminalState: 'none', - handHistory: [], - pendingTerminalAction: null, + terminalState: 'folded-by-you', }); - expect(onTurnChanged).not.toHaveBeenCalled(); + 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('omits the check-only endsStreet flag when calling a raise', () => { - const makeMove = jest.fn(); - const transitions: unknown[] = []; - const controller = { - handState: spacepokerStateCodec.encode({ - gameState: { handler: SpHandler.MidRound, myTurn: true, N: 3n }, - playerHoleCards: [1n, 2n], - playerBoost: false, - opponentHoleCards: null, - opponentBoost: null, - communityCards: [3n, 4n, 5n, null, null], - halfPot: 3n, - lastRaise: 2n, - iRaisedLast: false, - handHistory: [{ player: 'opponent', action: 'raise', units: 2n }], - outcome: null, - terminalState: 'none', - terminalRecovery: null, - pendingTerminalAction: null, - coinTossIOpen: true, - unitSizeMojos: 10n, - displayMode: 'units', - }), - isChannelReady: () => true, - transitionFeatureState: (_gameType: string, _gameId: string, state: unknown) => { - transitions.push(state); - return spacepokerRegistration.decodeFeatureState( state) !== null; - }, - commitLocalGameAction: (request: LocalGameActionRequest) => { - if (request.command.type !== 'make-move') throw new Error('unexpected command'); - makeMove(request.id, request.command.readable); - transitions.push(request.state); - }, - makeMove, - } as unknown as LiveGamePort; + 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(controller), + liveSource(port, persisted), '7', - false, - EMPTY, 100n, 10n, - jest.fn(), EMPTY_GAME_TERMINAL_MODEL, - liveSource(controller).handState ?? undefined, ); return null; } - act(() => { renderer = create(React.createElement(Harness)); }); - act(() => { - hand?.handleCall(); - }); + expect(hand?.lastRaise).toBe(0n); - expect(transitions).toHaveLength(1); - expect(spacepokerRegistration.decodeFeatureState( transitions[0])).toMatchObject({ - gameState: { handler: SpHandler.BeginRound, myTurn: false, N: 2n }, - halfPot: 5n, - lastRaise: 0n, - handHistory: [ - { player: 'opponent', action: 'raise', units: 2n }, - { player: 'you', action: 'call' }, - ], - }); - expect(makeMove).toHaveBeenCalledWith('7', null); + 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.each([ - { action: 'raise' as const, lastRaise: 0n }, - { action: 'call' as const, lastRaise: 2n }, - ])('keeps the live React boundary bigint-safe for slider/$action', ({ action, lastRaise }) => { - const committed: LocalGameActionRequest[] = []; - let postCommitStateReads = 0; - - function Harness() { - const [, rerender] = React.useState(0); - const persistedRef = React.useRef( - spacepokerStateCodec.encode({ - gameState: { handler: SpHandler.MidRound, myTurn: true, N: 3n }, - playerHoleCards: [1n, 2n], - playerBoost: false, - opponentHoleCards: null, - opponentBoost: null, - communityCards: [3n, 4n, 5n, null, null], - halfPot: 3n, - lastRaise, - iRaisedLast: false, - handHistory: [], - outcome: null, - terminalState: 'none', - terminalRecovery: null, - pendingTerminalAction: null, - coinTossIOpen: true, - unitSizeMojos: 10n, - displayMode: 'units', + 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 controllerRef = React.useRef(null); - if (!controllerRef.current) { - const controller = { - isChannelReady: () => true, - commitLocalGameAction: (request: LocalGameActionRequest) => { - committed.push(request); - const canonical = spacepokerStateCodec.encode(request.state as SpacepokerHandState); - Object.defineProperty(canonical, 'state', { - get: () => { - postCommitStateReads += 1; - return request.state; - }, - enumerable: true, - }); - persistedRef.current = canonical; - rerender((value) => value + 1); - }, - } as unknown as LiveGamePort; - Object.defineProperty(controller, 'handState', { - get: () => persistedRef.current, - enumerable: false, - }); - controllerRef.current = controller; - } - return React.createElement(SpacePoker, { - handSource: liveSource(controllerRef.current), - gameId: '7', - iStarted: false, - gameplayEvent$: EMPTY, - betSize: '100', - unitSizeMojos: '10', - onTurnChanged: () => {}, - onGameLog: () => {}, - terminal: EMPTY_GAME_TERMINAL_MODEL, - }); - } - - act(() => { - renderer = create(React.createElement(Harness)); }); - if (action === 'raise') { - act(() => { - renderer!.root.findByType('input').props.onChange({ target: { value: '3' } }); - }); - } - const button = renderer!.root + const actionButtons = renderer!.root .findAllByType('button') - .find((candidate) => candidate.children[0] === (action === 'raise' ? 'Raise' : 'Call')); - expect(button).toBeDefined(); - expect(() => act(() => button!.props.onClick())).not.toThrow(); - - expect(committed).toHaveLength(1); - expect(postCommitStateReads).toBe(0); - expect(spacepokerRegistration.decodeFeatureState( committed[0].state)).toMatchObject( - action === 'raise' - ? { gameState: { myTurn: false }, lastRaise: 3n } - : { gameState: { myTurn: false }, lastRaise: 0n }, - ); + .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/games/spacepoker/ui/useSpacepokerHand.ts b/games/spacepoker/ui/useSpacepokerHand.ts index 02ef10970..8b1ffc981 100644 --- a/games/spacepoker/ui/useSpacepokerHand.ts +++ b/games/spacepoker/ui/useSpacepokerHand.ts @@ -1,22 +1,17 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { Program } from 'clvm-lib'; -import { Observable } from 'rxjs'; -import { GameplayEvent } from '../../host'; -import { requireLiveGameHandSource, type GameHandSource } from '../../host'; -import { useGameHost } from '../../host/ui'; -import type { - PersistedGameState, - GameTerminalModel, - StateUpdate, - LocalGameCommand, - SettlementOutcome, +import { + gameHandState, + requireLiveGameHandSource, + type GameHandSource, + type PersistedGameState, } from '../../host'; -import { reduceSpacepokerFeatureState, reduceSpacepokerSettlementState } from './handProposal'; +import { useGameHost } from '../../host/ui'; +import type { GameTerminalModel, SettlementOutcome } from '../../host'; import { spacepokerStateCodec, type SpacepokerDisplayMode, type SpacepokerHandState, - type PendingSpacepokerTerminalAction, type SpGameState, type SpHandEntry, type SpHandler as SpHandlerType, @@ -27,18 +22,20 @@ import { export type { SpacepokerDisplayMode, SpacepokerHandState, - PendingSpacepokerTerminalAction, SpGameState, SpHandEntry, SpOutcome, SpTerminalState, } from './serialize'; -const SPACEPOKER_XCH_DISPLAY_THRESHOLD_MOJOS = 1_000_000n; -// These mirror the handler names in the Chialisp. The UX tracks which -// handler is currently active; every OpponentMoved advances it to the -// next state in the sequence. myTurn is implicit: an OpponentMoved -// means it's now my turn; a makeMove means it's now theirs. +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, @@ -54,75 +51,6 @@ export function isTerminalSpacepokerHandler(handler: SpHandler): boolean { return handler === SpHandler.Showdown || handler === SpHandler.Folded; } -export function opponentTerminalAction(state: SpGameState): 'fold' | 'concede' | null { - if (state.handler === SpHandler.MidRound && !state.myTurn) return 'fold'; - // We are waiting for the opponent's final move. If settlement arrives - // without an `end` readable, they chose the no-reveal (flag) action. - if (state.handler === SpHandler.End && !state.myTurn) return 'concede'; - return null; -} - -export function voluntarySpacepokerSettlementAction( - outcome: SettlementOutcome, - state: SpGameState, -): { player: 'you' | 'opponent'; action: 'fold' | 'concede' } | null { - if (outcome !== 'accept_settlement' && outcome !== 'we_accepted') return null; - const action = - state.handler === SpHandler.MidRound - ? 'fold' - : state.handler === SpHandler.End - ? 'concede' - : null; - if (!action) return null; - return { - player: outcome === 'we_accepted' || state.myTurn ? 'you' : 'opponent', - action, - }; -} - -export function pendingTerminalActionMatchesFailure( - pending: PendingSpacepokerTerminalAction | null, - submission: 'make-move' | 'accept-settlement' | undefined, -): pending is PendingSpacepokerTerminalAction { - return pending != null && pending.submission === submission; -} - -export function retainsRevealedTerminalPresentation( - pending: PendingSpacepokerTerminalAction | null, - terminalState: SpTerminalState, - outcome: SettlementOutcome, -): boolean { - const voluntaryAcknowledgement = outcome === 'accept_settlement' || outcome === 'we_accepted'; - return voluntaryAcknowledgement && (pending?.action === 'reveal' || terminalState === 'revealed'); -} - -export function retainsVoluntaryTerminalPresentation( - terminalState: SpTerminalState, - outcome: SettlementOutcome, -): boolean { - const voluntaryAcknowledgement = outcome === 'accept_settlement' || outcome === 'we_accepted'; - return ( - voluntaryAcknowledgement && - (terminalState === 'folded-by-you' || - terminalState === 'folded-by-opponent' || - terminalState === 'conceded-by-you' || - terminalState === 'conceded-by-opponent') - ); -} - -export function terminalAutoSubmissionAllowed( - recovery: 'fold' | 'concede' | 'reveal' | null, -): boolean { - return recovery == null; -} - -export function terminalRecoveryAfterOpponentMove( - recovery: 'concede' | 'reveal' | null, - completesTerminalAction: boolean, -): 'concede' | 'reveal' | null { - return completesTerminalAction ? null : recovery; -} - export interface UseSpacepokerHandResult { gameState: SpGameState; playerHoleCards: [bigint, bigint] | null; @@ -138,8 +66,6 @@ export interface UseSpacepokerHandResult { outcome: SpOutcome | null; terminalOutcome: SettlementOutcome | null; terminalState: SpTerminalState; - terminalRecovery: 'concede' | 'reveal' | null; - retryTerminalAction: () => void; lastRaise: bigint; coinTossIOpen: boolean | null; unitSizeMojos: bigint; @@ -154,29 +80,6 @@ export interface UseSpacepokerHandResult { handleCheat: () => void; } -export function acceptedSettlementFromOpponent(handler: SpHandler): { - action: 'fold' | 'concede'; - terminalState: SpTerminalState; - nextHandler: SpHandler; -} { - if (handler === SpHandler.End) { - return { - action: 'concede', - terminalState: 'conceded-by-opponent', - nextHandler: SpHandler.Showdown, - }; - } - return { - action: 'fold', - terminalState: 'folded-by-opponent', - nextHandler: SpHandler.Folded, - }; -} - -function defaultDisplayModeForUnit(unitSizeMojos: bigint): SpacepokerDisplayMode { - return unitSizeMojos > SPACEPOKER_XCH_DISPLAY_THRESHOLD_MOJOS ? 'xch' : 'mojos'; -} - function formatXch(mojos: bigint, xchLabel: string): string { const sign = mojos < 0n ? '-' : ''; const abs = mojos < 0n ? -mojos : mojos; @@ -186,475 +89,121 @@ function formatXch(mojos: bigint, xchLabel: string): string { return `${sign}${frac ? `${whole}.${frac}` : whole} ${xchLabel}`; } -export function rollbackOptimisticTerminalHistory( - history: SpHandEntry[], - action: 'fold' | 'concede' | 'reveal', -): SpHandEntry[] { - const last = history[history.length - 1]; - return last?.player === 'you' && last.action === action ? history.slice(0, -1) : history; -} - -export function reconcilePendingTerminalHistory( - history: SpHandEntry[], - action: 'fold' | 'concede' | 'reveal' | null, - outcome: SettlementOutcome, -): SpHandEntry[] { - if (action === null) return history; - const confirmed = - outcome === 'accept_settlement' || - outcome === 'we_accepted' || - (action === 'reveal' && outcome === 'settled_cleanly'); - if (confirmed) return history; - if (action === 'reveal') { - const last = history[history.length - 1]; - if (last?.player === 'you' && last.action === 'reveal') { - return [...history.slice(0, -1), { player: 'you', action: 'failed' }]; - } - } - return rollbackOptimisticTerminalHistory(history, action); -} - -function spacepokerStateFromPersisted( - persisted: Readonly | null | undefined, -): SpacepokerHandState | undefined { - return spacepokerStateCodec.decode(persisted) ?? undefined; -} - export function useSpacepokerHand( handSource: GameHandSource, - _gameId: string, - _iStarted: boolean, - gameplayEvent$: Observable, + gameId: string, betSize: bigint, unitSizeMojos: bigint, - onTurnChanged: (isMyTurn: boolean) => void, terminal: GameTerminalModel, - initialPersistedState?: Readonly, ): UseSpacepokerHandResult { const { currencyLabels } = useGameHost(); - const interactive = handSource.interactionMode === 'live'; - if (unitSizeMojos <= 0n) { - throw new Error('Space Poker requires a positive unit size'); - } - const fallbackDisplayMode = defaultDisplayModeForUnit(unitSizeMojos); - const [initialHandState] = useState(() => spacepokerStateFromPersisted(initialPersistedState)); - if (initialHandState && initialHandState.unitSizeMojos !== unitSizeMojos) { + 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 [betUnit] = useState(initialHandState?.unitSizeMojos ?? unitSizeMojos); - const stackSize = betUnit > 0n ? betSize / betUnit : 0n; - const anteUnits = 1n; - - // The game always starts with CommitA as the first my-turn handler - // for whoever goes first. The protocol tells us via the first - // OpponentMoved whether we go first or second — we don't need to - // remember iStarted. Start with myTurn=false and let the first event - // (either OpponentMoved giving us the turn, or the auto-play effect - // for commitA) sort it out. - // - // Actually: the protocol fires the first my-turn handler immediately - // after proposal acceptance, before any OpponentMoved arrives. So - // the auto-play effect for CommitA needs to fire. We set myTurn - // based on iStarted just for the initial commitA, but after that the - // state is driven entirely by events. - const [gs, setGsRaw] = useState( - initialHandState?.gameState ?? { - handler: SpHandler.CommitA, - myTurn: !_iStarted, - N: 4n, - }, - ); - const [playerHoleCards, setPlayerHoleCardsRaw] = useState<[bigint, bigint] | null>( - initialHandState?.playerHoleCards ?? null, - ); - const [playerBoost, setPlayerBoostRaw] = useState(initialHandState?.playerBoost ?? false); - const [opponentHoleCards, setOpponentHoleCardsRaw] = useState<[bigint, bigint] | null>( - initialHandState?.opponentHoleCards ?? null, - ); - const [opponentBoost, setOpponentBoostRaw] = useState( - initialHandState?.opponentBoost ?? null, - ); - const [communityCards, setCommunityCardsRaw] = useState<(bigint | null)[]>( - initialHandState?.communityCards ?? [null, null, null, null, null], - ); - const [halfPot, setHalfPotRaw] = useState(initialHandState?.halfPot ?? anteUnits); - const [lastRaise, setLastRaiseRaw] = useState(initialHandState?.lastRaise ?? 0n); - const [iRaisedLast, setIRaisedLastRaw] = useState(initialHandState?.iRaisedLast ?? false); - const [handHistory, setHandHistoryRaw] = useState( - initialHandState?.handHistory ?? [], - ); - const [outcome, setOutcomeRaw] = useState(initialHandState?.outcome ?? null); - const [terminalState, setTerminalStateRaw] = useState( - initialHandState?.terminalState ?? 'none', - ); - const [terminalRecovery, setTerminalRecoveryRaw] = useState<'concede' | 'reveal' | null>( - initialHandState?.terminalRecovery ?? null, - ); - // Coin toss result: true = I open, false = opponent opens, null = not yet known - const [coinTossIOpen, setCoinTossIOpenRaw] = useState( - initialHandState?.coinTossIOpen ?? null, - ); - const [displayMode, setDisplayModeRaw] = useState( - initialHandState?.displayMode ?? fallbackDisplayMode, - ); - - const pot = 2n * halfPot + lastRaise; - const playerStack = stackSize - halfPot - (iRaisedLast ? lastRaise : 0n); - const opponentStack = stackSize - halfPot - (iRaisedLast ? 0n : lastRaise); - const gsRef = useRef(gs); + const interactive = handSource.interactionMode === 'live'; + const betUnit = state.unitSizeMojos; + const stackSize = betSize / 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); - const handFinishedRef = useRef( - initialHandState?.gameState.handler === SpHandler.Showdown || - initialHandState?.gameState.handler === SpHandler.Folded || - (initialHandState?.terminalState != null && initialHandState.terminalState !== 'none'), - ); - const coinTossIOpenRef = useRef(coinTossIOpen); - const communityCardsRef = useRef(communityCards); - const lastRaiseRef = useRef(lastRaise); - const outcomeRef = useRef(outcome); - const terminalStateRef = useRef(terminalState); - const terminalActionByUsRef = useRef<'fold' | 'concede' | 'reveal' | null>(null); - const terminalActionByOpponentRef = useRef<'fold' | 'concede' | 'reveal' | null>(null); - const terminalClosureRef = useRef(false); - const halfPotRef = useRef(halfPot); - const iRaisedLastRef = useRef(iRaisedLast); - const handHistoryRef = useRef(handHistory); - const stateRef = useRef( - initialHandState ?? { - gameState: gs, - playerHoleCards, - playerBoost, - opponentHoleCards, - opponentBoost, - communityCards, - halfPot, - lastRaise, - iRaisedLast, - handHistory, - outcome, - terminalState, - terminalRecovery, - pendingTerminalAction: null, - coinTossIOpen, - unitSizeMojos: betUnit, - displayMode, - }, - ); - - gsRef.current = gs; + const gameIdRef = useRef(gameId); handSourceRef.current = handSource; - gameIdRef.current = _gameId; - coinTossIOpenRef.current = coinTossIOpen; - communityCardsRef.current = communityCards; - lastRaiseRef.current = lastRaise; - halfPotRef.current = halfPot; - iRaisedLastRef.current = iRaisedLast; - handHistoryRef.current = handHistory; - terminalStateRef.current = terminalState; - - const projectState = useCallback((next: SpacepokerHandState) => { - stateRef.current = next; - gsRef.current = next.gameState; - coinTossIOpenRef.current = next.coinTossIOpen; - communityCardsRef.current = next.communityCards; - lastRaiseRef.current = next.lastRaise; - halfPotRef.current = next.halfPot; - iRaisedLastRef.current = next.iRaisedLast; - handHistoryRef.current = next.handHistory; - outcomeRef.current = next.outcome; - terminalStateRef.current = next.terminalState ?? 'none'; - setGsRaw(next.gameState); - setPlayerHoleCardsRaw(next.playerHoleCards); - setPlayerBoostRaw(next.playerBoost); - setOpponentHoleCardsRaw(next.opponentHoleCards); - setOpponentBoostRaw(next.opponentBoost); - setCommunityCardsRaw(next.communityCards); - setHalfPotRaw(next.halfPot); - setLastRaiseRaw(next.lastRaise); - setIRaisedLastRaw(next.iRaisedLast); - setHandHistoryRaw(next.handHistory); - setOutcomeRaw(next.outcome); - setTerminalStateRaw(next.terminalState ?? 'none'); - setTerminalRecoveryRaw(next.terminalRecovery ?? null); - setCoinTossIOpenRaw(next.coinTossIOpen); - setDisplayModeRaw(next.displayMode); - }, []); + gameIdRef.current = gameId; - const commitState = useCallback( - (update: (current: SpacepokerHandState) => SpacepokerHandState): boolean => { - const controller = requireLiveGameHandSource(handSourceRef.current); - const next = update(stateRef.current); - if (!controller.transitionFeatureState('spacepoker', gameIdRef.current, next)) { - return false; - } - projectState(next); - return true; - }, - [projectState], - ); - const setters = useMemo(() => { - const propertySetter = - (key: K) => - (update: StateUpdate) => - commitState((current) => ({ - ...current, - [key]: - typeof update === 'function' - ? (update as (value: SpacepokerHandState[K]) => SpacepokerHandState[K])(current[key]) - : update, - })); - return { - setTerminalRecovery: propertySetter('terminalRecovery'), - }; - }, [commitState]); - const { setTerminalRecovery } = setters; - const setDisplayMode = useCallback( - (update: StateUpdate) => { - if (handSourceRef.current.interactionMode === 'live') { - return commitState((current) => ({ - ...current, - displayMode: - typeof update === 'function' - ? (update as (value: SpacepokerDisplayMode) => SpacepokerDisplayMode)( - current.displayMode, - ) - : update, - })); - } - const current = stateRef.current; - const next = - typeof update === 'function' - ? (update as (value: SpacepokerDisplayMode) => SpacepokerDisplayMode)(current.displayMode) - : update; - stateRef.current = { ...current, displayMode: next }; - setDisplayModeRaw(next); - return true; - }, - [commitState], + const [terminalDisplayMode, setTerminalDisplayMode] = useState( + null, ); + const displayMode = interactive ? state.displayMode : (terminalDisplayMode ?? state.displayMode); - const commitActionState = useCallback( - (update: (current: SpacepokerHandState) => SpacepokerHandState): boolean => { - const controller = requireLiveGameHandSource(handSourceRef.current); - const next = update(stateRef.current); - if ( - !controller.transitionFeatureStateWithLocalTurn( - 'spacepoker', - gameIdRef.current, - next, - next.gameState.myTurn, - ) - ) { - return false; - } - projectState(next); - return true; - }, - [projectState], - ); + 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, - ): SpacepokerHandState | null => { + (update: (current: SpacepokerHandState) => SpacepokerHandState, command: LocalGameCommand) => { const controller = requireLiveGameHandSource(handSourceRef.current); - const next = update(stateRef.current); - controller.commitLocalGameAction({ - gameType: 'spacepoker', - id: gameIdRef.current, - state: next, - command, - }); - projectState(next); - return next; - }, - [projectState], - ); - - const rollbackPendingTerminalAction = useCallback( - (submission: 'make-move' | 'accept-settlement'): boolean => { - const pending = stateRef.current.pendingTerminalAction; - if (!pendingTerminalActionMatchesFailure(pending, submission)) return false; - const committed = commitActionState((current) => ({ - ...current, - gameState: pending.previousGameState, - handHistory: rollbackOptimisticTerminalHistory(current.handHistory, pending.action), - terminalState: pending.previousTerminalState, - terminalRecovery: pending.action === 'fold' ? null : pending.action, - pendingTerminalAction: null, - })); - if (!committed) return false; - terminalClosureRef.current = false; - handFinishedRef.current = false; - terminalActionByUsRef.current = null; - return true; + const id = gameIdRef.current; + if (!id) return; + const next = update(currentDurableState()); + 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 }, + ); }, - [commitActionState], + [currentDurableState], ); - const replaceWithGenericTerminalClosure = useCallback( - (_outcome: SettlementOutcome | null, current: SpGameState) => { - const pending = stateRef.current.pendingTerminalAction; - terminalClosureRef.current = true; - terminalActionByUsRef.current = null; - terminalActionByOpponentRef.current = null; - handFinishedRef.current = true; - const state = stateRef.current; - projectState({ - ...state, - gameState: { handler: SpHandler.Folded, myTurn: false, N: current.N }, - handHistory: pending - ? rollbackOptimisticTerminalHistory(state.handHistory, pending.action) - : state.handHistory, - pendingTerminalAction: null, - outcome: null, - terminalState: 'settled', - terminalRecovery: null, + 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 }, }); - onTurnChanged(false); }, - [onTurnChanged, projectState], + [currentDurableState], ); - const applySettlement = useCallback( - (outcome: SettlementOutcome) => { - const pending = stateRef.current.pendingTerminalAction; - handFinishedRef.current = true; - terminalClosureRef.current = true; - terminalActionByUsRef.current = null; - terminalActionByOpponentRef.current = null; - const current = { - ...stateRef.current, - handHistory: reconcilePendingTerminalHistory( - stateRef.current.handHistory, - pending?.action ?? null, - outcome, - ), - }; - const next = reduceSpacepokerSettlementState(current, outcome); - outcomeRef.current = next.outcome; - projectState(next); - onTurnChanged(false); - }, - [onTurnChanged, projectState], - ); - - // ── OpponentMoved: the opponent made a move, it's now my turn ── - // Dispatch based on the readable tag. The tag tells us what the - // handler computed; it's the single source of truth for what happened. + const autoFiredSnapshotRef = useRef | null>(null); useEffect(() => { - if (!interactive) return; - const sub = gameplayEvent$.subscribe({ - next: (evt: GameplayEvent) => { - if (terminalClosureRef.current) return; - if ('Settled' in evt) { - if (evt.Settled.gameId !== gameIdRef.current) return; - applySettlement(evt.Settled.outcome); - return; - } - if ('MoveRejected' in evt) { - if (evt.MoveRejected.gameId !== gameIdRef.current) return; - rollbackPendingTerminalAction('make-move'); - return; - } - if ('GameError' in evt) { - if (evt.GameError.gameId !== gameIdRef.current) return; - if ( - evt.GameError.source === 'action' && - evt.GameError.action && - rollbackPendingTerminalAction(evt.GameError.action) - ) { - return; - } - if (evt.GameError.source === 'terminal') { - replaceWithGenericTerminalClosure(null, gsRef.current); - } - return; - } - if (handFinishedRef.current) return; - - if ('OpponentMoved' in evt) { - if (evt.OpponentMoved.gameId && evt.OpponentMoved.gameId !== gameIdRef.current) return; - const next = { - ...reduceSpacepokerFeatureState(stateRef.current, { - type: 'opponent-moved', - readable: Uint8Array.from(evt.OpponentMoved.readable), - }), - pendingTerminalAction: null, - }; - if (next.gameState.handler === SpHandler.Showdown) { - handFinishedRef.current = true; - terminalActionByOpponentRef.current = 'reveal'; - } - outcomeRef.current = next.outcome; - projectState(next); - onTurnChanged(next.gameState.myTurn); - } else if ('GameMessage' in evt) { - if (evt.GameMessage.gameId && evt.GameMessage.gameId !== gameIdRef.current) return; - projectState( - reduceSpacepokerFeatureState(stateRef.current, { - type: 'game-message', - readable: Uint8Array.from(evt.GameMessage.readable), - }), - ); - } - }, - }); - - return () => sub.unsubscribe(); - }, [ - gameplayEvent$, - interactive, - onTurnChanged, - applySettlement, - projectState, - replaceWithGenericTerminalClosure, - rollbackPendingTerminalAction, - ]); + 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; - // ── Auto-play: moves that don't need user input ── - // CommitA, CommitB: always auto-play nil. - // BeginRound N=4 when coin toss says opponent opens: auto-play nil (pong). - // BeginRound/MidRound all-in checks: auto-play only when there is no - // outstanding raise and we have no remaining raise capacity. - // End: auto-play reveal or game-level accept. - useEffect(() => { - if (!interactive) return; - if (handFinishedRef.current) return; - if (!terminalAutoSubmissionAllowed(terminalRecovery)) return; - const { handler, myTurn, N } = gs; - if (!myTurn) return; - const controller = requireLiveGameHandSource(handSourceRef.current); - const gid = gameIdRef.current; - if (!gid) return; - if (!controller.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) { - commitLocalAction((current) => ({ ...current, gameState: { ...gs, myTurn: false } }), { - type: 'make-move', - readable: null, - }); + submitOnce( + (current) => ({ ...current, gameState: { ...current.gameState, myTurn: false } }), + { + type: 'make-move', + readable: null, + }, + ); return; } - - if (handler === SpHandler.BeginRound && N === 4n && coinTossIOpen === false) { - commitLocalAction((current) => ({ ...current, gameState: { ...gs, myTurn: false } }), { - type: 'make-move', - readable: null, - }); + 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) && - lastRaise === 0n && + state.lastRaise === 0n && playerStack <= 0n ) { if (handler === SpHandler.BeginRound) { - commitLocalAction( + submitOnce( (current) => ({ ...current, gameState: { handler: SpHandler.MidRound, myTurn: false, N }, @@ -663,14 +212,13 @@ export function useSpacepokerHand( { type: 'make-move', readable: Program.fromBigInt(0n) }, ); } else { - const next = - N === 1n - ? { handler: SpHandler.End, myTurn: false, N: 1n } - : { handler: SpHandler.BeginRound, myTurn: false, N: N - 1n }; - commitLocalAction( + submitOnce( (current) => ({ ...current, - gameState: next, + 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: [ @@ -683,74 +231,37 @@ export function useSpacepokerHand( } return; } - - if (handler === SpHandler.End) { - const currentOutcome = outcomeRef.current; - if (!currentOutcome) return; - const action = currentOutcome.result >= 0n ? 'reveal' : 'accept'; - const optimisticHistoryAction = action === 'reveal' ? 'reveal' : 'concede'; - const previousTerminalState = terminalState; - const pending: PendingSpacepokerTerminalAction = { - action: optimisticHistoryAction, - submission: action === 'reveal' ? 'make-move' : 'accept-settlement', - previousTerminalState, - previousGameState: gs, - }; - if (action === 'reveal') { - const committed = commitLocalAction( + 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', - pendingTerminalAction: pending, }), { type: 'make-move', readable: null }, ); - if (!committed) return; - terminalActionByUsRef.current = 'reveal'; } else { - const committed = commitLocalAction( + submitOnce( (current) => ({ ...current, gameState: { handler: SpHandler.Showdown, myTurn: false, N }, handHistory: [...current.handHistory, { player: 'you', action: 'concede' }], terminalState: 'conceded-by-you', - pendingTerminalAction: pending, }), { type: 'accept-settlement' }, ); - if (!committed) return; - terminalActionByUsRef.current = 'concede'; } - handFinishedRef.current = true; - return; } - }, [ - gs, - interactive, - outcome, - coinTossIOpen, - lastRaise, - playerStack, - terminalState, - terminalRecovery, - commitLocalAction, - ]); - - const retryTerminalAction = useCallback(() => { - if (terminalRecovery != null) setTerminalRecovery(null); - }, [setTerminalRecovery, terminalRecovery]); + }, [commitLocalAction, interactive, persistedState, playerStack, state, terminal.type]); const handleCheck = useCallback(() => { - requireLiveGameHandSource(handSourceRef.current); - const gid = gameIdRef.current; - if (!gid) return; commitLocalAction( - (state) => ({ - ...state, - gameState: { handler: SpHandler.MidRound, myTurn: false, N: state.gameState.N }, - handHistory: [...state.handHistory, { player: 'you', action: 'check' }], + (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) }, ); @@ -758,101 +269,59 @@ export function useSpacepokerHand( const handleRaise = useCallback( (units: bigint) => { - requireLiveGameHandSource(handSourceRef.current); - const gid = gameIdRef.current; - if (!gid) return; - const mojoAmount = units * betUnit; commitLocalAction( - (state) => ({ - ...state, - gameState: { handler: SpHandler.MidRound, myTurn: false, N: state.gameState.N }, - halfPot: state.halfPot + state.lastRaise, + (current) => ({ + ...current, + gameState: { handler: SpHandler.MidRound, myTurn: false, N: current.gameState.N }, + halfPot: current.halfPot + current.lastRaise, lastRaise: units, iRaisedLast: true, - handHistory: [...state.handHistory, { player: 'you', action: 'raise', units }], + handHistory: [...current.handHistory, { player: 'you', action: 'raise', units }], }), - { type: 'make-move', readable: Program.fromBigInt(mojoAmount) }, + { type: 'make-move', readable: Program.fromBigInt(units * betUnit) }, ); }, [betUnit, commitLocalAction], ); const handleCall = useCallback(() => { - requireLiveGameHandSource(handSourceRef.current); - const gid = gameIdRef.current; - if (!gid) return; - const current = stateRef.current; - const next = - current.gameState.N === 1n - ? { handler: SpHandler.End, myTurn: false, N: 1n } - : { - handler: SpHandler.BeginRound, - myTurn: false, - N: current.gameState.N - 1n, - }; - const action = current.lastRaise > 0n ? 'call' : 'check'; commitLocalAction( - (state) => ({ - ...state, - gameState: next, - halfPot: state.halfPot + state.lastRaise, - lastRaise: 0n, - handHistory: [ - ...state.handHistory, - { - player: 'you', - action, - ...(action === 'check' ? { endsStreet: true } : {}), - }, - ], - outcome: current.gameState.N === 1n ? null : state.outcome, - }), + (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(() => { - requireLiveGameHandSource(handSourceRef.current); - const gid = gameIdRef.current; - if (!gid) return; - const current = stateRef.current; - // "Fold" is a UX betting action. Protocol-wise this accepts the current - // settlement; Space Poker has no fold move in its handlers or validators. - const previousTerminalState = terminalState; - const pending: PendingSpacepokerTerminalAction = { - action: 'fold', - submission: 'accept-settlement', - previousTerminalState, - previousGameState: current.gameState, - }; - const committed = commitLocalAction( - (state) => ({ - ...state, - gameState: { - handler: SpHandler.Folded, - myTurn: false, - N: state.gameState.N, - }, - handHistory: [...state.handHistory, { player: 'you', action: 'fold' }], + commitLocalAction( + (current) => ({ + ...current, + gameState: { handler: SpHandler.Folded, myTurn: false, N: current.gameState.N }, + handHistory: [...current.handHistory, { player: 'you', action: 'fold' }], terminalState: 'folded-by-you', - pendingTerminalAction: pending, }), { type: 'accept-settlement' }, ); - if (!committed) return; - handFinishedRef.current = true; - terminalActionByUsRef.current = 'fold'; - }, [commitLocalAction, terminalState]); + }, [commitLocalAction]); const handleCheat = useCallback(() => { - requireLiveGameHandSource(handSourceRef.current); - const gid = gameIdRef.current; - if (!gid) return; commitLocalAction( - (state) => ({ - ...state, - gameState: { ...state.gameState, myTurn: false }, - }), + (current) => ({ ...current, gameState: { ...current.gameState, myTurn: false } }), { type: 'cheat', moverShare: 0n }, ); }, [commitLocalAction]); @@ -868,24 +337,22 @@ export function useSpacepokerHand( ); return { - gameState: gs, - playerHoleCards, - playerBoost, - opponentHoleCards, - opponentBoost, - communityCards, + gameState: state.gameState, + playerHoleCards: state.playerHoleCards, + playerBoost: state.playerBoost, + opponentHoleCards: state.opponentHoleCards, + opponentBoost: state.opponentBoost, + communityCards: state.communityCards, pot, playerStack, opponentStack, betUnit, - handHistory, - outcome, + handHistory: state.handHistory, + outcome: state.outcome, terminalOutcome: terminal.outcome, - terminalState, - terminalRecovery, - retryTerminalAction, - lastRaise, - coinTossIOpen, + terminalState: state.terminalState, + lastRaise: state.lastRaise, + coinTossIOpen: state.coinTossIOpen, unitSizeMojos: betUnit, displayMode, setDisplayMode, From 4f59096207db1c64b3fc3c540e0e1a812c282092 Mon Sep 17 00:00:00 2001 From: Bram Cohen Date: Fri, 21 Aug 2026 17:23:19 -0700 Subject: [PATCH 07/12] Make local game action acceptance explicit. Keep queued candidates separate from canonical game state so delayed acceptance and rejection remain correct across protocol replay and browser restore. --- FRONTEND_ARCHITECTURE.md | 51 ++-- GAME_WRITING_GUIDE.md | 23 +- UX_NOTIFICATIONS.md | 10 +- front-end/src/hooks/SessionController.ts | 31 ++- front-end/src/hooks/useGameSession.ts | 15 +- front-end/src/lib/gameMountRegistry.tsx | 12 +- front-end/src/lib/gameRegistry.ts | 19 ++ front-end/src/lib/session/normalization.ts | 2 + front-end/src/lib/session/persistence.ts | 37 ++- front-end/src/lib/session/saveEnvelope.ts | 9 +- front-end/src/lib/session/sessionMachine.ts | 5 +- .../src/lib/session/sessionMachineGame.ts | 211 +++++++++++++++-- .../lib/session/sessionMachineInterpreter.ts | 4 +- .../session/sessionMachineNotifications.ts | 26 ++- .../src/lib/session/sessionMachineRuntime.ts | 20 +- .../src/lib/session/sessionMachineTypes.ts | 13 +- front-end/src/lib/session/sessionSnapshot.ts | 25 ++ .../src/lib/session/terminalFinalization.ts | 2 +- front-end/src/lib/session/types.ts | 24 +- .../lib/tests/game_feature_reducers.test.ts | 5 + .../src/lib/tests/game_mount_registry.test.ts | 19 +- .../src/lib/tests/game_state_codecs.test.ts | 8 + .../load_wasm.calpoker_completion.test.ts | 9 +- .../tests/message_protocol.transport.test.ts | 29 ++- front-end/src/lib/tests/save.state.test.ts | 6 +- .../lib/tests/session_machine.compose.test.ts | 1 + .../session_machine.feature_state.test.ts | 2 + .../lib/tests/session_machine.krunk.test.ts | 48 ++++ .../tests/session_machine_interpreter.test.ts | 220 +++++++++++++++++- .../lib/tests/session_model_roundtrip.test.ts | 1 + .../session_save_envelope.boundary.test.ts | 3 +- .../tests/session_save_envelope.fixtures.ts | 3 + .../session_save_envelope.roundtrip.test.ts | 33 +++ .../session_save_envelope.validation.test.ts | 35 +++ .../lib/tests/terminal_finalization.test.ts | 3 +- .../lib/tests/terminal_game_controls.test.tsx | 1 + front-end/src/types/ChiaGaming.ts | 1 + games/calpoker/ui/Calpoker.tsx | 4 + games/calpoker/ui/calPoker.test.ts | 107 ++++++++- .../ui/components/CaliforniaPoker.tsx | 6 + games/calpoker/ui/play.tsx | 1 + games/calpoker/ui/serialize.ts | 30 ++- .../calpoker/ui/types/CaliforniapokerProps.ts | 2 + games/calpoker/ui/useCalpokerHand.ts | 15 +- games/host/index.ts | 12 - games/krunk/rust/tests/sim.rs | 132 ++++++++++- games/krunk/ui/krunk.test.ts | 41 ++-- games/krunk/ui/serialize.ts | 34 +-- games/spacepoker/ui/SpacePoker.tsx | 13 +- games/spacepoker/ui/serialize.ts | 29 ++- games/spacepoker/ui/spacePoker.test.ts | 62 +++++ games/spacepoker/ui/useSpacepokerHand.ts | 4 +- src/session_phases/effects.rs | 31 +++ src/session_phases/mod.rs | 38 ++- src/session_phases/on_chain.rs | 80 +++++-- src/simulator/tests/session_phases_sim.rs | 47 +++- wasm/contract.d.ts | 8 +- wasm/src/mod.rs | 15 +- 58 files changed, 1450 insertions(+), 227 deletions(-) diff --git a/FRONTEND_ARCHITECTURE.md b/FRONTEND_ARCHITECTURE.md index aeb4d74c1..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 -`14`. Because the project is +`15`. Because the project is still alpha, every other version is deleted wholesale without decoding or -migration. A decoded v14 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 v14 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 @@ -519,11 +519,16 @@ 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. Games dispatch a -`GameIntent`; Rust/WASM accepts protocol commands first, then one machine -transition commits the candidate state and local turn together. A synchronous -failure or `MoveRejected` therefore cannot enter authority or a save. +`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 `handState` only from current machine authority and combines it with +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 @@ -543,10 +548,13 @@ 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. @@ -561,9 +569,11 @@ 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. The current v14 envelope makes +settled. The current v15 envelope makes `gameInstances` plus `lastDisplayedGameId` the only persisted game protocol -presentation and stores the canonical `GameProtocolPresentation` discriminant. +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. @@ -1423,10 +1433,11 @@ Space Poker keeps its hand history and terminal presentation inside 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. -Terminal reveal, concession, and fold candidates commit only when Rust accepts -the intent. `MoveRejected` leaves the candidate uncommitted. There is no -optimistic rollback or retry-recovery subsystem; unexpected failures are shown -by shared host error UX, and the game never observes the chain itself. +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: @@ -1590,8 +1601,8 @@ 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-v14 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` → v14 presentation snapshot encoder | +| `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 | @@ -1601,7 +1612,7 @@ not to limit concurrency. | `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` | v14 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_WRITING_GUIDE.md b/GAME_WRITING_GUIDE.md index dea9f799c..c04c9f549 100644 --- a/GAME_WRITING_GUIDE.md +++ b/GAME_WRITING_GUIDE.md @@ -372,10 +372,16 @@ type GameIntent = mojo-denominated `moverShare` and candidate feature state. It is not a normal gameplay fallback. -The command and candidate commit atomically: an immediate command failure -throws, and `move-rejected` leaves the candidate uncommitted. Unexpected -`ActionFailed` errors go to the shared host error UX, not back into game state. -For accepted protocol intents, the host applies `state` through +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. @@ -425,7 +431,10 @@ input)` must preserve already-initialized member state. `iStarted` identifies - `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. + 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. @@ -492,8 +501,8 @@ enforces this rule. The following are frontend implementation details, not APIs for games: -- Raw WASM payload types such as `GameStatus`, `ActionFailed`, and - `ProposalMade` +- 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 diff --git a/UX_NOTIFICATIONS.md b/UX_NOTIFICATIONS.md index 681aedba6..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` | diff --git a/front-end/src/hooks/SessionController.ts b/front-end/src/hooks/SessionController.ts index 73e89f0a9..0b16c776c 100644 --- a/front-end/src/hooks/SessionController.ts +++ b/front-end/src/hooks/SessionController.ts @@ -33,6 +33,8 @@ import { completeRegisteredGames } from '../lib/gameIdentities'; import { catalogGameTypeFromWire } from '../lib/gameIdentities'; import { markClientErrorReported } from '../lib/clientError'; +export type GameCommandDisposition = 'rejected' | 'queued' | 'applied'; + export interface WasmFields { serializedGameSession: Uint8Array; gameSessionSchemaVersion: bigint; @@ -831,7 +833,8 @@ export class SessionController implements PollingGameSession { result: WasmResult | undefined, action: string, gameId: string, - ): boolean { + actionKind: 'make_move' | 'accept_settlement' | 'cheat', + ): GameCommandDisposition { const required = requireWasmResult(result); const rejected = required.events.some( (event) => @@ -839,8 +842,15 @@ export class SessionController implements PollingGameSession { event.Notification.MoveRejected?.id != null && String(event.Notification.MoveRejected.id) === gameId, ); + const applied = required.events.some( + (event) => + 'Notification' in event && + event.Notification.LocalActionApplied?.id != null && + String(event.Notification.LocalActionApplied.id) === gameId && + event.Notification.LocalActionApplied.action === actionKind, + ); this.processCommandResult(required, action); - return !rejected; + return rejected ? 'rejected' : applied ? 'applied' : 'queued'; } private isTerminalPresentationEvent(event: GameSessionEvent): boolean { @@ -1596,12 +1606,12 @@ export class SessionController implements PollingGameSession { } } - makeMove(gameId: string, readable: Program | null): boolean { + makeMove(gameId: string, readable: Program | null): GameCommandDisposition { if (!this.cradle) throw new Error('no cradle'); try { const bytes = clvmToBytes(readable); const result = this.cradle.make_move(gameId, bytes); - return this.processGameCommandResult(result, 'make move', gameId); + return this.processGameCommandResult(result, 'make move', gameId, 'make_move'); } catch (e) { const msg = extractErrorMessage(e); console.error('[wasm] makeMove failed:', msg); @@ -1616,11 +1626,16 @@ export class SessionController implements PollingGameSession { } } - acceptSettlement(gameId: string): boolean { + acceptSettlement(gameId: string): GameCommandDisposition { if (!this.cradle) throw new Error('no cradle'); try { const result = this.cradle.acceptSettlement(gameId); - return this.processGameCommandResult(result, 'accept settlement', gameId); + return this.processGameCommandResult( + result, + 'accept settlement', + gameId, + 'accept_settlement', + ); } catch (e) { const msg = extractErrorMessage(e); console.error('[wasm] acceptSettlement failed:', msg); @@ -1635,11 +1650,11 @@ export class SessionController implements PollingGameSession { } } - cheat(gameId: string, moverShare: bigint): boolean { + cheat(gameId: string, moverShare: bigint): GameCommandDisposition { if (!this.cradle) throw new Error('no cradle'); try { const result = this.cradle.cheat(gameId, moverShare); - return this.processGameCommandResult(result, 'cheat', gameId); + return this.processGameCommandResult(result, 'cheat', gameId, 'cheat'); } catch (e) { const msg = extractErrorMessage(e); console.error('[wasm] cheat failed:', msg); diff --git a/front-end/src/hooks/useGameSession.ts b/front-end/src/hooks/useGameSession.ts index d938e1ca5..b1fdab022 100644 --- a/front-end/src/hooks/useGameSession.ts +++ b/front-end/src/hooks/useGameSession.ts @@ -27,7 +27,7 @@ import type { SessionMachineEvent, } from '../lib/session/sessionMachineTypes'; import type { RegisteredGameType } from '../lib/session/types'; -import { REGISTERED_GAMES } from '../lib/gameRegistry'; +import { projectRegisteredPendingCandidates, REGISTERED_GAMES } from '../lib/gameRegistry'; import { markClientErrorReported, wasClientErrorReported } from '../lib/clientError'; import { liveGameHandOrigin, type GameHandSource } from '@games/host'; import { log } from '../services/log'; @@ -215,13 +215,22 @@ export function useGameSession( }), [controller, dispatch, runtime], ); + const projectedHandState = useMemo(() => { + const game = machineState.model.game; + return projectRegisteredPendingCandidates( + game.activeGameType, + game.handState, + game.currentHandIds, + game.pendingCandidates, + ); + }, [machineState.model.game]); const liveHandSource = useMemo( () => ({ interactionMode: 'live', - handState: machineState.model.game.handState, + handState: projectedHandState, port: liveGamePort, }), - [liveGamePort, machineState.model.game.handState], + [liveGamePort, projectedHandState], ); useEffect(() => { runtime.setRender(setMachineState); diff --git a/front-end/src/lib/gameMountRegistry.tsx b/front-end/src/lib/gameMountRegistry.tsx index 471b51871..56faeb0d4 100644 --- a/front-end/src/lib/gameMountRegistry.tsx +++ b/front-end/src/lib/gameMountRegistry.tsx @@ -18,11 +18,13 @@ function gameInstances(model: SessionModel): GameMountView['instances'] { ); } -function canActById(model: SessionModel): GameMountView['canActById'] { +export function gameCanActById(model: SessionModel): GameMountView['canActById'] { return Object.fromEntries( Object.entries(model.game.instances).map(([id, instance]) => [ id, - instance.presentation === 'off-chain-my-turn' || instance.presentation === 'on-chain-my-turn', + !model.game.pendingCandidates[id] && + (instance.presentation === 'off-chain-my-turn' || + instance.presentation === 'on-chain-my-turn'), ]), ); } @@ -35,11 +37,11 @@ export function renderLiveGameMount( if (!isCatalogGameType(gameType)) throw new Error(`Unsupported game mount: ${gameType}`); const common = { handOrigin: session.handOrigin, - handState: session.sessionModel.game.handState, + handState: session.handSource.handState, lastDisplayedId: session.sessionModel.game.lastDisplayedId, activeIds: session.sessionModel.game.activeIds, currentHandIds: session.sessionModel.game.currentHandIds, - canActById: canActById(session.sessionModel), + canActById: gameCanActById(session.sessionModel), iStarted: session.iStarted, playerNumber: session.playerNumber, instances: gameInstances(session.sessionModel), @@ -72,7 +74,7 @@ export function renderFrozenGameMount( lastDisplayedId: model.game.lastDisplayedId, currentHandIds: model.game.currentHandIds, activeIds: model.game.activeIds, - canActById: canActById(model), + canActById: gameCanActById(model), instances: gameInstances(model), iStarted: options.iStarted, playerNumber: options.iStarted ? 1 : 2, diff --git a/front-end/src/lib/gameRegistry.ts b/front-end/src/lib/gameRegistry.ts index 5f5ccb1a6..75784bba7 100644 --- a/front-end/src/lib/gameRegistry.ts +++ b/front-end/src/lib/gameRegistry.ts @@ -9,6 +9,7 @@ import type { } from '@games/host'; import type { GameStateCodec, PersistedGameState } from './session/gameStateCodec'; import type { HandProposalBase, HandProposal } from './session/types'; +import type { PendingGameCandidate } from './session/types'; import { formatMojos } from '../util'; export type { CatalogGameType } from '../generated/gamePresets'; @@ -186,6 +187,24 @@ export function applyRegisteredFeatureState( return registration.stateCodec.encode(next); } +export function projectRegisteredPendingCandidates( + gameType: RegisteredGameType, + current: PersistedGameState | null, + currentHandIds: readonly string[], + pendingCandidates: Readonly>, +): PersistedGameState | null { + let projected = current; + for (const id of currentHandIds) { + const pending = pendingCandidates[id]; + if (!pending) continue; + if (pending.gameType !== gameType || pending.id !== id) { + throw new Error(`Internal pending candidate identity mismatch for game ${id}`); + } + projected = applyRegisteredFeatureState(gameType, projected, id, pending.featureState); + } + return projected; +} + export function selectRegisteredGameOutcome( gameType: RegisteredGameType, current: PersistedGameState | null, diff --git a/front-end/src/lib/session/normalization.ts b/front-end/src/lib/session/normalization.ts index f7d3f034b..09b86e296 100644 --- a/front-end/src/lib/session/normalization.ts +++ b/front-end/src/lib/session/normalization.ts @@ -120,6 +120,7 @@ export function createSessionModel(partial: SessionModelInput = {}): SessionMode lastDisplayedId: null, activeGameType: REGISTERED_GAMES[0].gameType, handState: null, + pendingCandidates: {}, queue: [], ...game, instances, @@ -156,6 +157,7 @@ export function clearDerivedGamePresentation(model: SessionModel): SessionModel instances: {}, lastDisplayedId: null, handState: null, + pendingCandidates: {}, }, }; } diff --git a/front-end/src/lib/session/persistence.ts b/front-end/src/lib/session/persistence.ts index c5367cedd..143f7543a 100644 --- a/front-end/src/lib/session/persistence.ts +++ b/front-end/src/lib/session/persistence.ts @@ -16,6 +16,7 @@ import type { import { SESSION_SAVE_SCHEMA, SESSION_SAVE_VERSION } from './saveEnvelope'; import { decodePersistedGameState, + decodeGameFeatureState, encodeHandProposalExtras, gameHandMembershipDescription, isCatalogGameType, @@ -33,7 +34,7 @@ import { INITIAL_CHANNEL_STATUS_MODEL, normalizeSessionPresentation, } from './normalization'; -import type { BetweenHandModeModel, HandProposal, SessionModel } from './types'; +import type { BetweenHandModeModel, HandProposal, LocalActionKind, SessionModel } from './types'; import { isTerminalChannelSnapshot } from './selectors'; import { parseComposeDraftState, @@ -321,6 +322,36 @@ function parsePresentation(value: unknown): SessionPresentationSave { if (fields.handState !== null && decodedHandState === null) { throw new Error('Garbled save: invalid handState'); } + if (!Array.isArray(fields.pendingCandidates)) { + throw new Error('Garbled save: invalid pendingCandidates'); + } + const pendingCandidates = fields.pendingCandidates.map((value, index) => { + const pending = requireRecord(value, `pendingCandidates[${index}]`); + if (!isCatalogGameType(pending.gameType)) { + throw new Error(`Garbled save: invalid pendingCandidates[${index}].gameType`); + } + const id = requireString(pending.id, `pendingCandidates[${index}].id`); + const action = parseDiscriminant( + pending.action, + new Set(['make_move', 'accept_settlement', 'cheat']), + `pendingCandidates[${index}].action`, + ); + const featureState = decodeGameFeatureState(pending.gameType, pending.featureState); + if (featureState === null) { + throw new Error(`Garbled save: invalid pendingCandidates[${index}].featureState`); + } + if ( + pending.gameType !== fields.activeGameType || + !currentHandGameIds.includes(id) || + !activeGameIds.includes(id) + ) { + throw new Error(`Garbled save: pending candidate ${id} is not an active hand member`); + } + return { gameType: pending.gameType, id, action, featureState }; + }); + if (new Set(pendingCandidates.map((pending) => pending.id)).size !== pendingCandidates.length) { + throw new Error('Garbled save: duplicate pending candidate id'); + } const lastOutcomeWin = fields.lastOutcomeWin === null ? null @@ -370,6 +401,7 @@ function parsePresentation(value: unknown): SessionPresentationSave { gameInstances, activeGameType: fields.activeGameType, handState: decodedHandState?.persisted ?? null, + pendingCandidates, channelStatus: decodeChannelStatusPayload(fields.channelStatus), lastOutcomeWin, myRunningBalance: (() => { @@ -688,6 +720,9 @@ export function decodeSessionSaveEnvelope(value: unknown): ParsedSessionSave { lastDisplayedId, activeGameType: save.activeGameType, handState, + pendingCandidates: Object.fromEntries( + save.pendingCandidates.map((pending) => [pending.id, pending]), + ), queue: parseQueuedNotifications(save.gameNotifQueue), }, betweenHand: { diff --git a/front-end/src/lib/session/saveEnvelope.ts b/front-end/src/lib/session/saveEnvelope.ts index bf438ef5a..48a00f4f7 100644 --- a/front-end/src/lib/session/saveEnvelope.ts +++ b/front-end/src/lib/session/saveEnvelope.ts @@ -3,6 +3,7 @@ import type { PersistedGameState } from './gameStateCodec'; import type { GameProtocolPresentation } from './gameSlice'; import type { BetweenHandModeModel, + LocalActionKind, NotificationKind, ProposalGroupDisposition, ProposalGroupOrigin, @@ -10,7 +11,7 @@ import type { } from './types'; export const SESSION_SAVE_SCHEMA = 'chia-gaming-session' as const; -export const SESSION_SAVE_VERSION = 14n; +export const SESSION_SAVE_VERSION = 15n; export type BlockchainType = 'simulator' | 'walletconnect'; @@ -105,6 +106,12 @@ export interface SessionPresentationSave { gameInstances: Record; activeGameType: RegisteredGameType; handState: PersistedGameState | null; + pendingCandidates: Array<{ + gameType: RegisteredGameType; + id: string; + action: LocalActionKind; + featureState: unknown; + }>; channelStatus: ChannelStatusPayload | null; lastOutcomeWin: 'win' | 'lose' | 'tie' | null; myRunningBalance: string; diff --git a/front-end/src/lib/session/sessionMachine.ts b/front-end/src/lib/session/sessionMachine.ts index 29caafc30..8ae263ea5 100644 --- a/front-end/src/lib/session/sessionMachine.ts +++ b/front-end/src/lib/session/sessionMachine.ts @@ -125,7 +125,10 @@ export function reduceSessionMachine( case 'notification-insufficient-balance': case 'notification-abandoned': case 'feature-state': - case 'local-game-action-committed': + case 'local-game-action-staged': + case 'local-game-action-applied': + case 'local-action-applied': + case 'discard-pending-candidate': return reduceDurableGameEvent(state, event); default: diff --git a/front-end/src/lib/session/sessionMachineGame.ts b/front-end/src/lib/session/sessionMachineGame.ts index 257a7dc21..9df96e061 100644 --- a/front-end/src/lib/session/sessionMachineGame.ts +++ b/front-end/src/lib/session/sessionMachineGame.ts @@ -2,6 +2,7 @@ import { applyHandProposalToComposeDraft } from './composeDraft'; import { gameSliceReducer, type GameSlice } from './gameSlice'; import { applyRegisteredFeatureState, + decodeGameFeatureState, isCatalogGameType, reduceRegisteredGameState, selectRegisteredGameOutcome, @@ -25,7 +26,10 @@ export type DurableGameEvent = Extract< | { type: 'notification-insufficient-balance' } | { type: 'notification-abandoned' } | { type: 'feature-state' } - | { type: 'local-game-action-committed' } + | { type: 'local-game-action-staged' } + | { type: 'local-game-action-applied' } + | { type: 'local-action-applied' } + | { type: 'discard-pending-candidate' } >; function gameSliceFromModel(model: SessionModel): GameSlice { @@ -44,6 +48,20 @@ function withGameSlice(model: SessionModel, game: GameSlice): SessionModel { return { ...model, game: { ...model.game, ...game } }; } +function withoutPendingIds(model: SessionModel, ids: readonly string[]): SessionModel { + if (!ids.some((id) => model.game.pendingCandidates[id])) return model; + const removed = new Set(ids); + return { + ...model, + game: { + ...model.game, + pendingCandidates: Object.fromEntries( + Object.entries(model.game.pendingCandidates).filter(([id]) => !removed.has(id)), + ), + }, + }; +} + function withGameInput( state: SessionMachineState, input: Parameters[2], @@ -74,17 +92,26 @@ export function reduceDurableGameEvent( event: DurableGameEvent, ): SessionMachineTransition { switch (event.type) { - case 'game': + case 'game': { + const cleared = + event.action.type === 'remove-group' + ? withoutPendingIds(state.model, event.action.groupIds) + : event.action.type === 'settled' + ? withoutPendingIds(state.model, [event.action.id]) + : event.action.type === 'abandoned' + ? { ...state.model, game: { ...state.model.game, pendingCandidates: {} } } + : state.model; return { state: { ...state, model: withGameSlice( - state.model, - gameSliceReducer(gameSliceFromModel(state.model), event.action), + cleared, + gameSliceReducer(gameSliceFromModel(cleared), event.action), ), }, effects: [], }; + } case 'notification-accepted-group': { const proposal = selectProposalGroupByMemberId(state.model, event.id); if (!proposal) { @@ -109,7 +136,12 @@ export function reduceDurableGameEvent( origin: proposal.origin, gameType: proposal.handProposal.gameType, }); - const modelWithGame = withGameSlice(state.model, game); + const modelWithGame = withGameSlice( + first + ? { ...state.model, game: { ...state.model.game, pendingCandidates: {} } } + : state.model, + game, + ); return withGameInput( { ...state, @@ -183,7 +215,8 @@ export function reduceDurableGameEvent( }); } case 'notification-game-terminal': { - const game = gameSliceReducer(gameSliceFromModel(state.model), { + const modelWithoutPending = withoutPendingIds(state.model, [event.id]); + const game = gameSliceReducer(gameSliceFromModel(modelWithoutPending), { type: 'settled', id: event.id, terminal: event.terminal, @@ -192,7 +225,7 @@ export function reduceDurableGameEvent( const base = { ...state, model: { - ...withGameSlice(state.model, game), + ...withGameSlice(modelWithoutPending, game), betweenHand: isLast ? { ...state.model.betweenHand, @@ -226,12 +259,15 @@ export function reduceDurableGameEvent( }; } case 'notification-move-rejected': - return withGameInput(state, { - type: 'move-rejected', - gameId: event.id, - tag: event.tag, - message: event.message, - }); + return withGameInput( + { ...state, model: withoutPendingIds(state.model, [event.id]) }, + { + type: 'move-rejected', + gameId: event.id, + tag: event.tag, + message: event.message, + }, + ); case 'notification-insufficient-balance': { const proposal = selectProposalGroupByMemberId(state.model, event.id); if (!proposal) { @@ -241,7 +277,7 @@ export function reduceDurableGameEvent( type: 'remove-group', groupIds: proposal.memberIds, }); - const modelWithGame = withGameSlice(state.model, game); + const modelWithGame = withoutPendingIds(withGameSlice(state.model, game), proposal.memberIds); const cleared = clearProposalIds( { ...state, @@ -282,14 +318,17 @@ export function reduceDurableGameEvent( ...state, model: { ...withGameSlice(state.model, game), - game: { ...withGameSlice(state.model, game).game, handState: null }, + game: { + ...withGameSlice(state.model, game).game, + handState: null, + pendingCandidates: {}, + }, }, }, effects: [{ type: 'clear-derived-game-presentation' }], }; } - case 'feature-state': - case 'local-game-action-committed': { + case 'feature-state': { if (event.gameType !== state.model.game.activeGameType) { throw new Error( `Internal feature-state gameType ${event.gameType} does not match active ${state.model.game.activeGameType}`, @@ -304,23 +343,149 @@ export function reduceDurableGameEvent( event.id, event.state, ); - const feature = { + return { state: { ...state, model: { ...state.model, game: { ...state.model.game, handState } }, }, effects: [], }; - if (event.type === 'feature-state') return feature; - const game = gameSliceReducer(gameSliceFromModel(feature.state.model), { + } + case 'local-game-action-staged': { + if (event.gameType !== state.model.game.activeGameType) { + throw new Error( + `Internal pending candidate gameType ${event.gameType} does not match active ${state.model.game.activeGameType}`, + ); + } + if (!state.model.game.activeIds.includes(event.id)) { + throw new Error(`Internal pending candidate game id ${event.id} is not active`); + } + if (!state.model.game.currentHandIds.includes(event.id)) { + throw new Error( + `Internal pending candidate game id ${event.id} is not a current hand member`, + ); + } + if (state.model.game.pendingCandidates[event.id]) { + throw new Error(`Internal pending candidate already exists for game ${event.id}`); + } + const featureState = decodeGameFeatureState(event.gameType, event.state); + if (featureState === null) { + throw new Error(`Internal pending candidate payload is invalid for ${event.gameType}`); + } + return { + state: { + ...state, + model: { + ...state.model, + game: { + ...state.model.game, + pendingCandidates: { + ...state.model.game.pendingCandidates, + [event.id]: { + gameType: event.gameType, + id: event.id, + action: event.action, + featureState, + }, + }, + }, + }, + }, + effects: [{ type: 'persist-session' }], + }; + } + case 'local-game-action-applied': { + if (event.gameType !== state.model.game.activeGameType) { + throw new Error( + `Internal applied candidate gameType ${event.gameType} does not match active ${state.model.game.activeGameType}`, + ); + } + if ( + !state.model.game.activeIds.includes(event.id) || + !state.model.game.currentHandIds.includes(event.id) + ) { + throw new Error( + `Internal applied candidate game id ${event.id} is not an active hand member`, + ); + } + if (state.model.game.pendingCandidates[event.id]) { + throw new Error(`Internal applied candidate conflicts with pending game ${event.id}`); + } + const featureState = decodeGameFeatureState(event.gameType, event.state); + if (featureState === null) { + throw new Error(`Internal applied candidate payload is invalid for ${event.gameType}`); + } + const handState = applyRegisteredFeatureState( + event.gameType, + state.model.game.handState, + event.id, + featureState, + ); + const applied = { + ...state, + model: { + ...state.model, + game: { ...state.model.game, handState }, + }, + }; + const game = gameSliceReducer(gameSliceFromModel(applied.model), { type: 'local-turn', id: event.id, isMyTurn: false, - channelState: feature.state.model.channel.status.state, + channelState: applied.model.channel.status.state, }); return { - state: { ...feature.state, model: withGameSlice(feature.state.model, game) }, - effects: [], + state: { ...applied, model: withGameSlice(applied.model, game) }, + effects: [{ type: 'persist-session' }], + }; + } + case 'local-action-applied': { + const pending = state.model.game.pendingCandidates[event.id]; + if (!pending) return { state, effects: [] }; + if (pending.action !== event.action) { + throw new Error( + `LocalActionApplied ${event.id} action ${event.action} does not match pending ${pending.action}`, + ); + } + const handState = applyRegisteredFeatureState( + pending.gameType, + state.model.game.handState, + event.id, + pending.featureState, + ); + const promoted = { + ...state, + model: { + ...state.model, + game: { + ...state.model.game, + handState, + pendingCandidates: Object.fromEntries( + Object.entries(state.model.game.pendingCandidates).filter(([id]) => id !== event.id), + ), + }, + }, + }; + const game = gameSliceReducer(gameSliceFromModel(promoted.model), { + type: 'local-turn', + id: event.id, + isMyTurn: false, + channelState: promoted.model.channel.status.state, + }); + return { + state: { ...promoted, model: withGameSlice(promoted.model, game) }, + effects: [{ type: 'persist-session' }], + }; + } + case 'discard-pending-candidate': { + const pending = state.model.game.pendingCandidates[event.id]; + if (!pending) return { state, effects: [] }; + if (event.action !== undefined && pending.action !== event.action) { + return { state, effects: [] }; + } + return { + state: { ...state, model: withoutPendingIds(state.model, [event.id]) }, + effects: [{ type: 'persist-session' }], }; } default: diff --git a/front-end/src/lib/session/sessionMachineInterpreter.ts b/front-end/src/lib/session/sessionMachineInterpreter.ts index 6e9e16657..45ce5fddb 100644 --- a/front-end/src/lib/session/sessionMachineInterpreter.ts +++ b/front-end/src/lib/session/sessionMachineInterpreter.ts @@ -1,4 +1,4 @@ -import type { SessionController } from '../../hooks/SessionController'; +import type { GameCommandDisposition, SessionController } from '../../hooks/SessionController'; import { protocolIdForCatalog } from '../gameIdentities'; import { encodeGameProposalParameters } from '../gameProposalCodec'; import { validateHandProposal } from '../gameRegistry'; @@ -30,7 +30,7 @@ export class SessionMachineInterpreter { constructor(private readonly dependencies: SessionMachineInterpreterDependencies) {} - runLocalGameCommand(command: LocalGameCommand, id: string): boolean { + runLocalGameCommand(command: LocalGameCommand, id: string): GameCommandDisposition { switch (command.type) { case 'make-move': return this.dependencies.controller.makeMove(id, command.readable); diff --git a/front-end/src/lib/session/sessionMachineNotifications.ts b/front-end/src/lib/session/sessionMachineNotifications.ts index 412d01729..dc918219b 100644 --- a/front-end/src/lib/session/sessionMachineNotifications.ts +++ b/front-end/src/lib/session/sessionMachineNotifications.ts @@ -21,6 +21,12 @@ import type { } from './sessionMachineTypes'; const ERROR_CHANNEL_STATUSES = new Set(['ResolvedStale', 'Failed']); +const TERMINAL_CHANNEL_STATUSES = new Set([ + 'ResolvedClean', + 'ResolvedUnrolled', + 'ResolvedStale', + 'Failed', +]); const LOCAL_CANCEL_REASONS = new Set(['SupersededByIncoming', 'PeerProposalPending', 'GameActive']); type Reducer = (state: SessionMachineState, event: SessionMachineEvent) => SessionMachineTransition; @@ -62,6 +68,11 @@ export function reduceSessionNotification( if (!payload) return { state, effects: [] }; const status = channelStatusModelFromPayload(payload); step({ type: 'channel-status', status }); + if (TERMINAL_CHANNEL_STATUSES.has(payload.state)) { + for (const id of Object.keys(current.model.game.pendingCandidates)) { + step({ type: 'discard-pending-candidate', id }); + } + } const generation = current.coordination.channelEnrichmentGeneration + 1; current = { ...current, @@ -448,7 +459,13 @@ export function reduceSessionNotification( return { state: current, effects }; } - if ('MoveRejected' in notification && notification.MoveRejected) { + if ('LocalActionApplied' in notification && notification.LocalActionApplied) { + step({ + type: 'local-action-applied', + id: String(notification.LocalActionApplied.id), + action: notification.LocalActionApplied.action, + }); + } else if ('MoveRejected' in notification && notification.MoveRejected) { step({ type: 'notification-move-rejected', id: String(notification.MoveRejected.id), @@ -457,6 +474,13 @@ export function reduceSessionNotification( }); } else if ('ActionFailed' in notification && notification.ActionFailed) { const failed = notification.ActionFailed as ActionFailedPayload; + if (failed.id !== undefined && failed.action !== undefined) { + step({ + type: 'discard-pending-candidate', + id: String(failed.id), + action: failed.action, + }); + } step({ type: 'enqueue-error', kind: 'action-failed', message: String(failed.reason) }); } return { state: current, effects }; diff --git a/front-end/src/lib/session/sessionMachineRuntime.ts b/front-end/src/lib/session/sessionMachineRuntime.ts index 37091fd65..2e158b00c 100644 --- a/front-end/src/lib/session/sessionMachineRuntime.ts +++ b/front-end/src/lib/session/sessionMachineRuntime.ts @@ -110,6 +110,9 @@ export class SessionMachineRuntime { if (!game.activeIds.includes(request.id)) { throw new Error(`Internal local action game id ${request.id} is not active`); } + if (game.pendingCandidates[request.id]) { + throw new Error(`Internal local action game ${request.id} already has a pending candidate`); + } const instance = game.instances[request.id]; if (!instance) { throw new Error(`Internal local action game id ${request.id} has no game instance`); @@ -120,16 +123,25 @@ export class SessionMachineRuntime { ) { throw new Error(`Internal local action for game ${request.id} attempted outside our turn`); } - if (decodeGameFeatureState(request.gameType, request.state) === null) { + const featureState = decodeGameFeatureState(request.gameType, request.state); + if (featureState === null) { throw new Error(`Internal local action payload is invalid for ${request.gameType}`); } - if (!this.interpreter.runLocalGameCommand(request.command, request.id)) return; + const action = + request.command.type === 'make-move' + ? 'make_move' + : request.command.type === 'accept-settlement' + ? 'accept_settlement' + : 'cheat'; + const disposition = this.interpreter.runLocalGameCommand(request.command, request.id); + if (disposition === 'rejected') return; this.dispatch({ - type: 'local-game-action-committed', + type: disposition === 'applied' ? 'local-game-action-applied' : 'local-game-action-staged', gameType: request.gameType, id: request.id, - state: request.state, + action, + state: featureState, }); } diff --git a/front-end/src/lib/session/sessionMachineTypes.ts b/front-end/src/lib/session/sessionMachineTypes.ts index 1c0f12a95..2c70fa63a 100644 --- a/front-end/src/lib/session/sessionMachineTypes.ts +++ b/front-end/src/lib/session/sessionMachineTypes.ts @@ -6,6 +6,7 @@ import type { BetweenHandModeModel, GameTerminalModel, HandProposal, + LocalActionKind, ProposalGroupDisposition, ProposalGroupModel, QueuedNotificationModel, @@ -162,11 +163,21 @@ export type SessionMachineEvent = state: unknown; } | { - type: 'local-game-action-committed'; + type: 'local-game-action-staged'; gameType: RegisteredGameType; id: string; + action: LocalActionKind; state: unknown; } + | { + type: 'local-game-action-applied'; + gameType: RegisteredGameType; + id: string; + action: LocalActionKind; + state: unknown; + } + | { type: 'local-action-applied'; id: string; action: LocalActionKind } + | { type: 'discard-pending-candidate'; id: string; action?: LocalActionKind } | { type: 'request-accept-proposal'; id: string } | { type: 'request-cancel-proposal'; id: string } | { type: 'request-propose-game'; handProposal: HandProposal } diff --git a/front-end/src/lib/session/sessionSnapshot.ts b/front-end/src/lib/session/sessionSnapshot.ts index 3a5f8f4ad..089f82505 100644 --- a/front-end/src/lib/session/sessionSnapshot.ts +++ b/front-end/src/lib/session/sessionSnapshot.ts @@ -3,6 +3,7 @@ import type { SavedHandProposal, SessionPresentationSave } from './saveEnvelope' import { encodeComposeDraftState } from './persistenceBetweenHands'; import { encodeHandProposalExtras, + decodeGameFeatureState, isCatalogGameType, packageFor, validateHandProposal, @@ -103,11 +104,35 @@ export function snapshotFromSessionModel( if (model.game.handState !== null) { requireCatalogGameType(model.game.handState.gameType, 'handState.gameType'); } + for (const [id, pending] of Object.entries(model.game.pendingCandidates)) { + if ( + pending.id !== id || + pending.gameType !== model.game.activeGameType || + !model.game.currentHandIds.includes(id) || + !model.game.activeIds.includes(id) || + decodeGameFeatureState(pending.gameType, pending.featureState) === null + ) { + throw new Error(`Session invariant broken: invalid pending candidate ${id}`); + } + } return { activeGameIds: model.game.activeIds, activeGameType: requireCatalogGameType(model.game.activeGameType, 'activeGameType'), handState: model.game.handState, + pendingCandidates: model.game.currentHandIds.flatMap((id) => { + const pending = model.game.pendingCandidates[id]; + return pending + ? [ + { + gameType: pending.gameType, + id: pending.id, + action: pending.action, + featureState: pending.featureState, + }, + ] + : []; + }), currentHandGameIds: model.game.currentHandIds, currentHandOrigin: model.game.currentHandOrigin, lastDisplayedGameId: model.game.lastDisplayedId, diff --git a/front-end/src/lib/session/terminalFinalization.ts b/front-end/src/lib/session/terminalFinalization.ts index 2c5a9c372..c835eb589 100644 --- a/front-end/src/lib/session/terminalFinalization.ts +++ b/front-end/src/lib/session/terminalFinalization.ts @@ -66,7 +66,7 @@ export function finalizeTerminalSession( const handState = structuredClone(args.model.game.handState); const model: SessionModel = { ...args.model, - game: { ...args.model.game, handState }, + game: { ...args.model.game, handState, pendingCandidates: {} }, }; const terminalFields = structuredClone({ terminal: { diff --git a/front-end/src/lib/session/types.ts b/front-end/src/lib/session/types.ts index 61bdb26b2..e5bd15fd8 100644 --- a/front-end/src/lib/session/types.ts +++ b/front-end/src/lib/session/types.ts @@ -11,7 +11,6 @@ import type { ComposeDraftState } from './composeDraft'; import type { PersistedGameState } from './gameStateCodec'; export type { - GameTurnState, GameTerminalType, GameTerminalModel, HandProposalBase, @@ -19,7 +18,6 @@ export type { } from '@games/host'; import type { GameTerminalModel, - GameTurnState, HandProposal as HostHandProposal, ProposalGroupOrigin, } from '@games/host'; @@ -28,6 +26,27 @@ import type { CatalogGameType } from '../../generated/gamePresets'; export type RegisteredGameType = CatalogGameType; export type { CatalogGameType }; +export type LocalActionKind = 'make_move' | 'accept_settlement' | 'cheat'; + +export type GameTurnState = + | 'my-turn' + | 'their-turn' + | 'playing-on-chain' + | 'replaying' + | 'opponent-illegal-move' + | 'submitting-timeout' + | 'finishing' + | 'finishing-waiting-timeout' + | 'finishing-spending' + | 'ended'; + +export interface PendingGameCandidate { + gameType: RegisteredGameType; + id: string; + action: LocalActionKind; + featureState: unknown; +} + export type HandProposal = Omit & { gameType: CatalogGameType; }; @@ -166,6 +185,7 @@ export interface GameModel { lastDisplayedId: string | null; activeGameType: RegisteredGameType; handState: PersistedGameState | null; + pendingCandidates: Record; queue: QueuedNotificationModel[]; } diff --git a/front-end/src/lib/tests/game_feature_reducers.test.ts b/front-end/src/lib/tests/game_feature_reducers.test.ts index d033dd8b9..50a1fc7c9 100644 --- a/front-end/src/lib/tests/game_feature_reducers.test.ts +++ b/front-end/src/lib/tests/game_feature_reducers.test.ts @@ -501,6 +501,7 @@ describe('canonical feature gameplay reducers', () => { moveNumber: 1n, isPlayerTurn: false, iStarted: false, + error: null, }; const cards = readable(ints([0n, 1n, 2n]), ints([3n, 4n, 5n])); const projected = reduceCalpokerFeatureState(current, { @@ -547,6 +548,7 @@ describe('canonical feature gameplay reducers', () => { moveNumber: testCase.moveNumber, isPlayerTurn: false, iStarted: testCase.iStarted, + error: null, }; const projected = reduceCalpokerDurableState(current, { @@ -585,6 +587,7 @@ describe('canonical feature gameplay reducers', () => { moveNumber: 1n, isPlayerTurn: false, iStarted: false, + error: null, }, { ...status(finalReadable), id: 'calpoker-1', iStarted: false }, ), @@ -599,6 +602,7 @@ describe('canonical feature gameplay reducers', () => { moveNumber: 2n, isPlayerTurn: true, iStarted: true, + error: null, }; expect( @@ -625,6 +629,7 @@ describe('canonical feature gameplay reducers', () => { moveNumber: 1n, isPlayerTurn: false, iStarted: true, + error: null, }; expect( reduceCalpokerDurableState(calpoker, { diff --git a/front-end/src/lib/tests/game_mount_registry.test.ts b/front-end/src/lib/tests/game_mount_registry.test.ts index 1f9599e6d..4b2d96560 100644 --- a/front-end/src/lib/tests/game_mount_registry.test.ts +++ b/front-end/src/lib/tests/game_mount_registry.test.ts @@ -7,7 +7,7 @@ import { } from '@games/host'; import type { UseGameSessionResult } from '../../hooks/useGameSession'; import { isCatalogGameType, packageFor } from '../gameRegistry'; -import { renderFrozenGameMount, renderLiveGameMount } from '../gameMountRegistry'; +import { gameCanActById, renderFrozenGameMount, renderLiveGameMount } from '../gameMountRegistry'; import { createSessionModel } from '../session/model'; const terminal = { @@ -91,7 +91,21 @@ describe('game mount registry', () => { ); it('passes the current machine snapshot and host-owned hand key to a live mount', () => { - const model = modelFor('calpoker'); + const base = modelFor('calpoker'); + const model = createSessionModel({ + ...base, + game: { + ...base.game, + pendingCandidates: { + '1': { + gameType: 'calpoker', + id: '1', + action: 'make_move', + featureState: {}, + }, + }, + }, + }); const port = { isChannelReady: () => true, dispatch: jest.fn() } as LiveGamePort; const session = { sessionModel: model, @@ -109,6 +123,7 @@ describe('game mount registry', () => { expect(mount.key).toBe('7'); expect(gameHandState(mount.props.handSource)).toBe(model.game.handState); expect(mount.props.handSource.interactionMode).toBe('live'); + expect(gameCanActById(model)['1']).toBe(false); }); it('cold-restores a frozen mount without protocol capabilities', () => { diff --git a/front-end/src/lib/tests/game_state_codecs.test.ts b/front-end/src/lib/tests/game_state_codecs.test.ts index 83aa998fa..82eef980e 100644 --- a/front-end/src/lib/tests/game_state_codecs.test.ts +++ b/front-end/src/lib/tests/game_state_codecs.test.ts @@ -19,6 +19,7 @@ describe('game-owned state codecs', () => { isPlayerTurn: true, iStarted: true, cardSelections: [1n], + error: null, }; const encoded = calpokerStateCodec.encode(state); expect(calpokerStateCodec.decode(encoded)).toEqual(state); @@ -59,6 +60,7 @@ describe('game-owned state codecs', () => { coinTossIOpen: null, unitSizeMojos: 10n, displayMode: 'mojos' as const, + error: null, }; const encoded = spacepokerStateCodec.encode(state); expect(spacepokerStateCodec.decode(encoded)).toEqual(state); @@ -86,6 +88,12 @@ describe('game-owned state codecs', () => { state: { ...state, terminalState: 'revealed' }, }), ).toBeNull(); + expect( + spacepokerStateCodec.decode({ + ...encoded, + state: { ...state, error: { tag: 'INVALID', message: '' } }, + }), + ).toBeNull(); }); it('round-trips Krunk live initialization and remains non-remountable', () => { diff --git a/front-end/src/lib/tests/load_wasm.calpoker_completion.test.ts b/front-end/src/lib/tests/load_wasm.calpoker_completion.test.ts index 35bc629d9..886dc4e3f 100644 --- a/front-end/src/lib/tests/load_wasm.calpoker_completion.test.ts +++ b/front-end/src/lib/tests/load_wasm.calpoker_completion.test.ts @@ -17,6 +17,7 @@ import { createSessionMachineState } from '../session/sessionMachine'; import { SessionMachineRuntime } from '../session/sessionMachineRuntime'; import type { HandProposal } from '../session/types'; import type { GameIntent, LiveGamePort } from '@games/host'; +import { projectRegisteredPendingCandidates } from '../gameRegistry'; import { addActiveSubscription, createActivePair, @@ -304,7 +305,13 @@ async function runRealCalpokerCompletionCase(poller: BlockchainPoller): Promise< { interactionMode: 'live', get handState() { - return runtime.getState().model.game.handState; + const game = runtime.getState().model.game; + return projectRegisteredPendingCandidates( + game.activeGameType, + game.handState, + game.currentHandIds, + game.pendingCandidates, + ); }, port: ports[index], }, diff --git a/front-end/src/lib/tests/message_protocol.transport.test.ts b/front-end/src/lib/tests/message_protocol.transport.test.ts index c6ff369a8..ef85688a6 100644 --- a/front-end/src/lib/tests/message_protocol.transport.test.ts +++ b/front-end/src/lib/tests/message_protocol.transport.test.ts @@ -44,6 +44,19 @@ describe('WASM result boundary', () => { expect(() => requireWasmResult(result)).toThrow('unknown notification'); }); + it('accepts the host-only local action notification', () => { + const result = wasmResult({ + events: [ + { + Notification: { + LocalActionApplied: { id: 1n, action: 'make_move' }, + }, + }, + ], + }); + expect(requireWasmResult(result)).toBe(result); + }); + it('requires outbound protocol messages to remain bytes', () => { const result = wasmResult({ events: [{ OutboundMessage: 'not bytes' } as unknown as WasmResult['events'][number]], @@ -611,9 +624,21 @@ describe('game action failure events', () => { } ).make_move = makeMove; - expect(blob.makeMove('41', null)).toBe(false); + expect(blob.makeMove('41', null)).toBe('rejected'); makeMove.mockReturnValue(wasmResult()); - expect(blob.makeMove('41', null)).toBe(true); + expect(blob.makeMove('41', null)).toBe('queued'); + makeMove.mockReturnValue( + wasmResult({ + events: [ + { + Notification: { + LocalActionApplied: { id: 41n, action: 'make_move' }, + }, + }, + ], + }), + ); + expect(blob.makeMove('41', null)).toBe('applied'); }); }); diff --git a/front-end/src/lib/tests/save.state.test.ts b/front-end/src/lib/tests/save.state.test.ts index a61fb680b..2840c57b1 100644 --- a/front-end/src/lib/tests/save.state.test.ts +++ b/front-end/src/lib/tests/save.state.test.ts @@ -421,7 +421,7 @@ describe('flat state', () => { }, handState: { gameType: 'spacepoker', - version: 3n, + version: 4n, state: { gameState: { handler: 2n, myTurn: true, N: 4n }, playerHoleCards: [1n, 2n], @@ -438,6 +438,7 @@ describe('flat state', () => { coinTossIOpen: null, unitSizeMojos: 10n, displayMode: 'mojos', + error: null, }, }, activeGameType: 'spacepoker', @@ -485,7 +486,7 @@ describe('flat state', () => { }, handState: { gameType: 'calpoker', - version: 2n, + version: 3n, state: { playerHand: [8n, 7n, 6n, 5n], opponentHand: [4n, 3n, 2n, 1n], @@ -493,6 +494,7 @@ describe('flat state', () => { isPlayerTurn: true, iStarted: false, cardSelections: [8n, 7n], + error: null, displaySnapshot: { gameState: 'selecting', winner: null, diff --git a/front-end/src/lib/tests/session_machine.compose.test.ts b/front-end/src/lib/tests/session_machine.compose.test.ts index db9dafee0..eeb0f5de6 100644 --- a/front-end/src/lib/tests/session_machine.compose.test.ts +++ b/front-end/src/lib/tests/session_machine.compose.test.ts @@ -125,6 +125,7 @@ describe('session machine behavior sequences', () => { isPlayerTurn: false, iStarted: true, + error: null, }), proposalGroups: [ diff --git a/front-end/src/lib/tests/session_machine.feature_state.test.ts b/front-end/src/lib/tests/session_machine.feature_state.test.ts index ec1c0a7db..1264ae350 100644 --- a/front-end/src/lib/tests/session_machine.feature_state.test.ts +++ b/front-end/src/lib/tests/session_machine.feature_state.test.ts @@ -53,6 +53,7 @@ describe('session machine behavior sequences', () => { isPlayerTurn: true, iStarted: true, + error: null, }, }); @@ -147,6 +148,7 @@ describe('session machine behavior sequences', () => { isPlayerTurn: false, iStarted: true, + error: null, }, }); diff --git a/front-end/src/lib/tests/session_machine.krunk.test.ts b/front-end/src/lib/tests/session_machine.krunk.test.ts index e22d70e76..f29c2ce09 100644 --- a/front-end/src/lib/tests/session_machine.krunk.test.ts +++ b/front-end/src/lib/tests/session_machine.krunk.test.ts @@ -10,6 +10,7 @@ import { createSessionMachineState, reduceSessionMachine } from '../session/sess import { reduceSessionNotification } from '../session/sessionMachineNotifications'; import { CALPOKER_TERMS, KRUNK_TERMS, run, send, trackProposal } from './session_machine.harness'; import { liveSave } from './session_save_envelope.fixtures'; +import { projectRegisteredPendingCandidates } from '../gameRegistry'; describe('session machine behavior sequences', () => { it('atomically replaces Krunk authority when the next group arrives after one member settles', () => { @@ -346,4 +347,51 @@ describe('session machine behavior sequences', () => { ), ).not.toThrow(); }); + + it('projects and promotes independent pending candidates for ordered Krunk IDs', () => { + let state = createSessionMachineState(createSessionModel()); + state = trackProposal(state, ['1', '2'], KRUNK_TERMS); + state = send(state, { + type: 'notification-accepted-group', + id: '1', + amount: '100', + iStarted: true, + isMyTurn: true, + }); + const canonical = state.model.game.handState; + const hand = krunkStateCodec.decode(canonical)!; + + state = send(state, { + type: 'local-game-action-staged', + gameType: 'krunk', + id: '2', + action: 'make_move', + state: { ...hand.games['2'], handler: 4n, myTurn: true }, + }); + state = send(state, { + type: 'local-game-action-staged', + gameType: 'krunk', + id: '1', + action: 'make_move', + state: { ...hand.games['1'], handler: 1n, myTurn: false, secretWord: 'CRANE' }, + }); + + expect(state.model.game.handState).toBe(canonical); + const projected = krunkStateCodec.decode( + projectRegisteredPendingCandidates( + 'krunk', + canonical, + state.model.game.currentHandIds, + state.model.game.pendingCandidates, + ), + )!; + expect(Object.keys(projected.games)).toEqual(['1', '2']); + expect(projected.games['1'].secretWord).toBe('CRANE'); + expect(projected.games['2'].handler).toBe(4n); + + state = send(state, { type: 'local-action-applied', id: '1', action: 'make_move' }); + expect(krunkStateCodec.decode(state.model.game.handState)!.games['1'].secretWord).toBe('CRANE'); + expect(krunkStateCodec.decode(state.model.game.handState)!.games['2'].handler).toBe(3n); + expect(Object.keys(state.model.game.pendingCandidates)).toEqual(['2']); + }); }); diff --git a/front-end/src/lib/tests/session_machine_interpreter.test.ts b/front-end/src/lib/tests/session_machine_interpreter.test.ts index 4913985a1..be9dde93e 100644 --- a/front-end/src/lib/tests/session_machine_interpreter.test.ts +++ b/front-end/src/lib/tests/session_machine_interpreter.test.ts @@ -17,6 +17,7 @@ import type { SessionMachineEvent } from '../session/sessionMachineTypes'; import { krunkStateCodec } from '@games/krunk/ui/serialize'; import { calpokerStateCodec } from '@games/calpoker/ui/serialize'; import { spacepokerStateCodec } from '@games/spacepoker/ui/serialize'; +import { projectRegisteredPendingCandidates } from '../gameRegistry'; import { wasmResult } from './message_protocol.harness'; const TERMS = { @@ -792,8 +793,11 @@ describe('session machine controller command failures', () => { }); describe('session machine local game action boundary', () => { - function localActionHarness(makeMove: SessionController['makeMove']) { - const controller = fakeController({ makeMove }); + function localActionHarness( + makeMove: SessionController['makeMove'], + overrides: Partial = {}, + ) { + const controller = fakeController({ makeMove, ...overrides }); const initial = stateWithProposals([{ memberIds: ['7'], handProposal: TERMS }]); const persisted: ReturnType[] = []; const rendered: ReturnType[] = []; @@ -822,7 +826,7 @@ describe('session machine local game action boundary', () => { } it('uses ordered Rust authority for opposite-turn Krunk members before the first move', () => { - const makeMove = jest.fn(() => true); + const makeMove = jest.fn(() => 'queued' as const); const runtime = new SessionMachineRuntime( stateWithProposals([{ memberIds: ['2', '4'], handProposal: KRUNK_TERMS, origin: 'local' }]), { @@ -872,7 +876,7 @@ describe('session machine local game action boundary', () => { }); it('uses Rust acceptance authority for the first Space Poker action', () => { - const makeMove = jest.fn(() => true); + const makeMove = jest.fn(() => 'queued' as const); const runtime = new SessionMachineRuntime( stateWithProposals([{ memberIds: ['7'], handProposal: SPACEPOKER_TERMS, origin: 'local' }]), { @@ -933,10 +937,11 @@ describe('session machine local game action boundary', () => { expect(rendered).toHaveLength(0); }); - it('commits accepted feature state and shared turn in one rendered transition', () => { - const makeMove = jest.fn(() => true); + it('stages after command success, projects optimistically, and promotes on applied', () => { + const makeMove = jest.fn(() => 'queued' as const); const { runtime, persisted, rendered } = localActionHarness(makeMove); const current = calpokerStateCodec.decode(runtime.getState().model.game.handState)!; + const canonical = runtime.getState().model.game.handState; runtime.commitLocalGameAction({ gameType: 'calpoker', @@ -947,12 +952,209 @@ describe('session machine local game action boundary', () => { expect(makeMove).toHaveBeenCalledTimes(1); expect(rendered).toHaveLength(1); - expect(calpokerStateCodec.decode(rendered[0].model.game.handState)).toMatchObject({ + expect(rendered[0].model.game.handState).toBe(canonical); + expect(rendered[0].model.game.pendingCandidates['7']).toMatchObject({ + id: '7', + action: 'make_move', + }); + expect( + calpokerStateCodec.decode( + projectRegisteredPendingCandidates( + 'calpoker', + rendered[0].model.game.handState, + rendered[0].model.game.currentHandIds, + rendered[0].model.game.pendingCandidates, + ), + ), + ).toMatchObject({ moveNumber: 1n, isPlayerTurn: false, }); - expect(rendered[0].model.game.instances['7'].presentation).toBe('off-chain-their-turn'); - expect(persisted).toHaveLength(0); + expect(rendered[0].model.game.instances['7'].presentation).toBe('off-chain-my-turn'); + expect(persisted).toHaveLength(1); + + runtime.dispatch({ + type: 'wasm-notification', + iStarted: false, + notification: { LocalActionApplied: { id: 7n, action: 'make_move' } }, + }); + expect(calpokerStateCodec.decode(runtime.getState().model.game.handState)).toMatchObject({ + moveNumber: 1n, + isPlayerTurn: false, + }); + expect(runtime.getState().model.game.pendingCandidates).toEqual({}); + expect(runtime.getState().model.game.instances['7'].presentation).toBe('off-chain-their-turn'); + expect(persisted).toHaveLength(2); + }); + + it('commits an immediately applied candidate without entering pending state or save', () => { + const makeMove = jest.fn(() => 'applied' as const); + const { runtime, persisted, rendered } = localActionHarness(makeMove); + const current = calpokerStateCodec.decode(runtime.getState().model.game.handState)!; + + runtime.commitLocalGameAction({ + gameType: 'calpoker', + id: '7', + state: { ...current, moveNumber: 1n, isPlayerTurn: false }, + command: { type: 'make-move', readable: null }, + }); + + expect(runtime.getState().model.game.pendingCandidates).toEqual({}); + expect(calpokerStateCodec.decode(runtime.getState().model.game.handState)).toMatchObject({ + moveNumber: 1n, + isPlayerTurn: false, + }); + expect(runtime.getState().model.game.instances['7'].presentation).toBe('off-chain-their-turn'); + expect(rendered).toHaveLength(1); + expect(persisted).toHaveLength(1); + expect(persisted[0].model.game.pendingCandidates).toEqual({}); + + const applied = runtime.getState(); + runtime.dispatch({ + type: 'wasm-notification', + iStarted: false, + notification: { LocalActionApplied: { id: 7n, action: 'make_move' } }, + }); + expect(runtime.getState()).toBe(applied); + expect(persisted).toHaveLength(1); + }); + + it('denies duplicate pending actions and reduces delayed rejection on canonical state', () => { + const { runtime } = localActionHarness(jest.fn(() => 'queued' as const)); + const current = calpokerStateCodec.decode(runtime.getState().model.game.handState)!; + const request = { + gameType: 'calpoker' as const, + id: '7', + state: { ...current, moveNumber: 1n, isPlayerTurn: false }, + command: { type: 'make-move' as const, readable: null }, + }; + runtime.commitLocalGameAction(request); + expect(() => runtime.commitLocalGameAction(request)).toThrow('already has a pending candidate'); + + runtime.dispatch({ + type: 'wasm-notification', + iStarted: false, + notification: { + MoveRejected: { id: 7n, tag: 'invalid', message: 'Try another move' }, + }, + }); + expect(runtime.getState().model.game.pendingCandidates).toEqual({}); + expect(runtime.getState().model.game.handState).toEqual( + calpokerStateCodec.encode({ + ...current, + error: { tag: 'invalid', message: 'Try another move' }, + }), + ); + }); + + it('discards a matching delayed cheat failure while retaining shared error UX', () => { + const { runtime } = localActionHarness( + jest.fn(() => 'queued' as const), + { + cheat: jest.fn(() => 'queued'), + }, + ); + const current = calpokerStateCodec.decode(runtime.getState().model.game.handState)!; + runtime.commitLocalGameAction({ + gameType: 'calpoker', + id: '7', + state: { ...current, moveNumber: 1n, isPlayerTurn: false }, + command: { type: 'cheat', moverShare: 0n }, + }); + + runtime.dispatch({ + type: 'wasm-notification', + iStarted: false, + notification: { + ActionFailed: { id: 7n, action: 'cheat', reason: 'queued cheat became stale' }, + }, + }); + expect(runtime.getState().model.game.pendingCandidates).toEqual({}); + expect(runtime.getState().model.channel.queue.at(-1)).toMatchObject({ + kind: 'action-failed', + message: 'queued cheat became stale', + }); + }); + + it('clears pending candidates when a hand is replaced or abandoned', () => { + const runtime = new SessionMachineRuntime( + stateWithProposals([ + { memberIds: ['7'], handProposal: TERMS }, + { memberIds: ['9'], handProposal: TERMS, origin: 'peer' }, + ]), + { + controller: fakeController({ + makeMove: () => 'queued', + clearDerivedGamePresentation: jest.fn(), + }), + iStarted: false, + restoring: false, + getRestoreStatus: () => 'idle', + getRestoreError: () => null, + onError: (error) => { + throw error; + }, + persist: async () => {}, + }, + ); + runtime.dispatch({ + type: 'notification-accepted-group', + id: '7', + amount: '20', + iStarted: false, + isMyTurn: true, + }); + const current = calpokerStateCodec.decode(runtime.getState().model.game.handState)!; + runtime.commitLocalGameAction({ + gameType: 'calpoker', + id: '7', + state: { ...current, moveNumber: 1n, isPlayerTurn: false }, + command: { type: 'make-move', readable: null }, + }); + runtime.dispatch({ + type: 'notification-accepted-group', + id: '9', + amount: '20', + iStarted: false, + isMyTurn: true, + }); + expect(runtime.getState().model.game.pendingCandidates).toEqual({}); + + const replacement = calpokerStateCodec.decode(runtime.getState().model.game.handState)!; + runtime.commitLocalGameAction({ + gameType: 'calpoker', + id: '9', + state: { ...replacement, moveNumber: 1n, isPlayerTurn: false }, + command: { type: 'make-move', readable: null }, + }); + runtime.dispatch({ type: 'notification-abandoned' }); + expect(runtime.getState().model.game.pendingCandidates).toEqual({}); + }); + + it('ignores an unmatched applied signal and fails fast on a mismatched pending action', () => { + const { runtime } = localActionHarness(jest.fn(() => 'queued' as const)); + const before = runtime.getState(); + runtime.dispatch({ + type: 'wasm-notification', + iStarted: false, + notification: { LocalActionApplied: { id: 7n, action: 'make_move' } }, + }); + expect(runtime.getState()).toBe(before); + + const current = calpokerStateCodec.decode(runtime.getState().model.game.handState)!; + runtime.commitLocalGameAction({ + gameType: 'calpoker', + id: '7', + state: { ...current, moveNumber: 1n, isPlayerTurn: false }, + command: { type: 'make-move', readable: null }, + }); + expect(() => + runtime.dispatch({ + type: 'wasm-notification', + iStarted: false, + notification: { LocalActionApplied: { id: 7n, action: 'accept_settlement' } }, + }), + ).toThrow('does not match pending'); }); it.each([ diff --git a/front-end/src/lib/tests/session_model_roundtrip.test.ts b/front-end/src/lib/tests/session_model_roundtrip.test.ts index da3ba628d..33d5e9efe 100644 --- a/front-end/src/lib/tests/session_model_roundtrip.test.ts +++ b/front-end/src/lib/tests/session_model_roundtrip.test.ts @@ -27,6 +27,7 @@ const CAL_HAND_STATE = calpokerStateCodec.encode({ moveNumber: 0n, isPlayerTurn: true, iStarted: true, + error: null, }); describe('session model round trips', () => { diff --git a/front-end/src/lib/tests/session_save_envelope.boundary.test.ts b/front-end/src/lib/tests/session_save_envelope.boundary.test.ts index dd045a440..d7b713053 100644 --- a/front-end/src/lib/tests/session_save_envelope.boundary.test.ts +++ b/front-end/src/lib/tests/session_save_envelope.boundary.test.ts @@ -83,6 +83,7 @@ describe('save boundary enforcement', () => { moveNumber: 1n, isPlayerTurn: true, iStarted: true, + error: null, }), betweenHandLastHandProposal: { my_contribution: '20', @@ -126,7 +127,7 @@ describe('save boundary enforcement', () => { errorSpy.mockRestore(); }); - it('deletes a malformed current-v14 metadata envelope read from IndexedDB', async () => { + it('deletes a malformed current-v15 metadata envelope read from IndexedDB', async () => { markSavedSession(); await writeSessionRecord( baseSave({ diff --git a/front-end/src/lib/tests/session_save_envelope.fixtures.ts b/front-end/src/lib/tests/session_save_envelope.fixtures.ts index a65c69c0c..ca375f60d 100644 --- a/front-end/src/lib/tests/session_save_envelope.fixtures.ts +++ b/front-end/src/lib/tests/session_save_envelope.fixtures.ts @@ -65,6 +65,7 @@ const PRESENTATION_KEYS = new Set([ 'activeGameType', 'gameInstances', 'handState', + 'pendingCandidates', 'channelStatus', 'lastOutcomeWin', 'myRunningBalance', @@ -126,6 +127,7 @@ function presentation(fields: LegacyFields): SessionPresentationSave { gameInstances: {}, activeGameType: 'calpoker', handState: null, + pendingCandidates: [], channelStatus: null, lastOutcomeWin: null, myRunningBalance: '0', @@ -251,6 +253,7 @@ export function activeSave(fields: LegacyFields = {}): SessionSave { moveNumber: 1n, isPlayerTurn: true, iStarted: true, + error: null, }), betweenHandLastHandProposal: { my_contribution: '20', diff --git a/front-end/src/lib/tests/session_save_envelope.roundtrip.test.ts b/front-end/src/lib/tests/session_save_envelope.roundtrip.test.ts index 7780d0e68..efe33450a 100644 --- a/front-end/src/lib/tests/session_save_envelope.roundtrip.test.ts +++ b/front-end/src/lib/tests/session_save_envelope.roundtrip.test.ts @@ -15,6 +15,7 @@ import { } from '../session/model'; import { ACTIVE_INSTANCE, + activeSave, baseSave, installSessionEnvelopeTestSetup, liveSave, @@ -95,6 +96,7 @@ describe('durable game envelope round trips', () => { isPlayerTurn: true, iStarted: true, cardSelections: [1n, 2n], + error: null, }), }, { @@ -116,6 +118,7 @@ describe('durable game envelope round trips', () => { coinTossIOpen: true, unitSizeMojos: 10n, displayMode: 'mojos', + error: null, }), }, { @@ -204,6 +207,35 @@ describe('durable game envelope round trips', () => { expect(sessionModelFromSave(loaded!).betweenHand.compose).toEqual(compose); }); + it('round-trips canonical hand state separately from a pending candidate', () => { + const save = activeSave(); + if (save.phase !== 'live') throw new Error('expected live fixture'); + const canonical = save.presentation.handState; + const featureState = { + ...calpokerStateCodec.decode(canonical)!, + moveNumber: 2n, + isPlayerTurn: false, + }; + const restored = sessionModelFromSave( + activeSave({ + pendingCandidates: [ + { gameType: 'calpoker', id: 'game-1', action: 'make_move', featureState }, + ], + }), + ); + + expect(restored.game.handState).toEqual(canonical); + expect(restored.game.pendingCandidates['game-1']).toEqual({ + gameType: 'calpoker', + id: 'game-1', + action: 'make_move', + featureState, + }); + expect(snapshotFromSessionModel(restored).pendingCandidates).toEqual([ + { gameType: 'calpoker', id: 'game-1', action: 'make_move', featureState }, + ]); + }); + it('round-trips a session with no lastHandProposal and an unsubmittable compose draft', () => { const model = createSessionModel(); const snapshot = snapshotFromSessionModel(model); @@ -269,6 +301,7 @@ describe('durable game envelope round trips', () => { isPlayerTurn: true, iStarted: true, cardSelections: [1n, 2n], + error: null, }); setProtocolIds(hashes); try { diff --git a/front-end/src/lib/tests/session_save_envelope.validation.test.ts b/front-end/src/lib/tests/session_save_envelope.validation.test.ts index 3817ddf60..4db596a0b 100644 --- a/front-end/src/lib/tests/session_save_envelope.validation.test.ts +++ b/front-end/src/lib/tests/session_save_envelope.validation.test.ts @@ -50,6 +50,7 @@ describe('validateSessionSaveEnvelope', () => { moveNumber: 1n, isPlayerTurn: true, iStarted: true, + error: null, }), betweenHandLastHandProposal: { my_contribution: '20', @@ -78,6 +79,7 @@ describe('validateSessionSaveEnvelope', () => { moveNumber: 1n, isPlayerTurn: true, iStarted: true, + error: null, }), betweenHandLastHandProposal: { my_contribution: '20', @@ -176,6 +178,7 @@ describe('validateSessionSaveEnvelope', () => { 'gameInstances', 'activeGameType', 'handState', + 'pendingCandidates', 'channelStatus', 'lastOutcomeWin', 'myRunningBalance', @@ -263,6 +266,7 @@ describe('validateSessionSaveEnvelope', () => { moveNumber: 1n, isPlayerTurn: true, iStarted: true, + error: null, }), activeGameType: 'spacepoker', }), @@ -315,6 +319,33 @@ describe('validateSessionSaveEnvelope', () => { ).toThrow('exactly match currentHandGameIds'); }); + it('strictly validates pending candidate identity, action, membership, and feature state', () => { + const base = activeSave(); + if (base.phase !== 'live') throw new Error('expected live fixture'); + const featureState = calpokerStateCodec.decode(base.presentation.handState)!; + expect(() => + validateSessionSaveEnvelope( + activeSave({ + pendingCandidates: [ + { gameType: 'calpoker', id: 'game-1', action: 'make_move', featureState }, + ], + }), + ), + ).not.toThrow(); + for (const pendingCandidates of [ + [ + { gameType: 'calpoker', id: 'game-1', action: 'make_move', featureState }, + { gameType: 'calpoker', id: 'game-1', action: 'cheat', featureState }, + ], + [{ gameType: 'calpoker', id: 'other', action: 'make_move', featureState }], + [{ gameType: 'calpoker', id: 'game-1', action: 'unknown', featureState }], + [{ gameType: 'calpoker', id: 'game-1', action: 'make_move', featureState: {} }], + [{ gameType: 'spacepoker', id: 'game-1', action: 'make_move', featureState }], + ]) { + expect(() => validateSessionSaveEnvelope(activeSave({ pendingCandidates }))).toThrow(); + } + }); + it.each([ [ 'calpoker', @@ -324,6 +355,7 @@ describe('validateSessionSaveEnvelope', () => { moveNumber: 1n, isPlayerTurn: true, iStarted: true, + error: null, }), {}, ], @@ -345,6 +377,7 @@ describe('validateSessionSaveEnvelope', () => { coinTossIOpen: null, unitSizeMojos: 10n, displayMode: 'mojos', + error: null, }), { spacepoker_unit_size: '10' }, ], @@ -504,6 +537,7 @@ describe('validateSessionSaveEnvelope', () => { moveNumber: 1n, isPlayerTurn: false, iStarted: false, + error: null, }), }, } @@ -536,6 +570,7 @@ describe('validateSessionSaveEnvelope', () => { coinTossIOpen: true, unitSizeMojos: 10n, displayMode: 'mojos', + error: null, }), betweenHandLastHandProposal: { my_contribution: '20', diff --git a/front-end/src/lib/tests/terminal_finalization.test.ts b/front-end/src/lib/tests/terminal_finalization.test.ts index 80c2156f1..933d7106a 100644 --- a/front-end/src/lib/tests/terminal_finalization.test.ts +++ b/front-end/src/lib/tests/terminal_finalization.test.ts @@ -39,7 +39,7 @@ const testIndexedDb = indexedDB; const liveCradle = new Uint8Array([1, 2, 3]); const handState = { gameType: 'calpoker', - version: 2n, + version: 3n, state: { playerHand: [8n, 7n, 6n, 5n], opponentHand: [4n, 3n, 2n, 1n], @@ -47,6 +47,7 @@ const handState = { isPlayerTurn: true, iStarted: true, cardSelections: [8n, 7n], + error: null, displaySnapshot: { gameState: 'selecting', winner: null, diff --git a/front-end/src/lib/tests/terminal_game_controls.test.tsx b/front-end/src/lib/tests/terminal_game_controls.test.tsx index 5417f8ad1..34ad921e7 100644 --- a/front-end/src/lib/tests/terminal_game_controls.test.tsx +++ b/front-end/src/lib/tests/terminal_game_controls.test.tsx @@ -221,6 +221,7 @@ describe('terminal game controls', () => { coinTossIOpen: true, unitSizeMojos: 10n, displayMode: 'mojos', + error: null, }); act(() => { diff --git a/front-end/src/types/ChiaGaming.ts b/front-end/src/types/ChiaGaming.ts index e5680e84d..0d7bc508a 100644 --- a/front-end/src/types/ChiaGaming.ts +++ b/front-end/src/types/ChiaGaming.ts @@ -41,6 +41,7 @@ const WASM_NOTIFICATION_TAGS = new Set([ 'InsufficientBalance', 'MoveRejected', 'ActionFailed', + 'LocalActionApplied', ]); function requireClosedNotification(value: unknown): void { diff --git a/games/calpoker/ui/Calpoker.tsx b/games/calpoker/ui/Calpoker.tsx index a056ae10b..b80556d93 100644 --- a/games/calpoker/ui/Calpoker.tsx +++ b/games/calpoker/ui/Calpoker.tsx @@ -4,6 +4,7 @@ import { CaliforniaPoker } from './components'; import { useCheatKeys } from '../../host/ui'; import { CalpokerDisplaySnapshotView, CalpokerOutcomeView } from './types/CaliforniapokerProps'; import type { GameInteractionMode, SettlementOutcome } from '../../host'; +import type { CalpokerError } from './serialize'; export interface CalpokerProps { outcome: CalpokerOutcomeView | undefined; @@ -23,6 +24,7 @@ export interface CalpokerProps { opponentName?: string; terminalOutcome?: SettlementOutcome | null; interactionMode?: GameInteractionMode; + error: CalpokerError | null; } const Calpoker: React.FC = ({ @@ -43,6 +45,7 @@ const Calpoker: React.FC = ({ opponentName, terminalOutcome, interactionMode = 'live', + error, }) => { useCheatKeys(handleCheat, interactionMode === 'live'); @@ -67,6 +70,7 @@ const Calpoker: React.FC = ({ opponentName={opponentName} terminalOutcome={terminalOutcome} interactionMode={interactionMode} + error={error} />
diff --git a/games/calpoker/ui/calPoker.test.ts b/games/calpoker/ui/calPoker.test.ts index b67c5a91a..46fe08e2e 100644 --- a/games/calpoker/ui/calPoker.test.ts +++ b/games/calpoker/ui/calPoker.test.ts @@ -150,6 +150,7 @@ describe('Calpoker fresh hand startup', () => { moveNumber: 0n, isPlayerTurn: true, iStarted: false, + error: null, }), isChannelReady: () => true, dispatch, @@ -185,6 +186,7 @@ describe('Calpoker fresh hand startup', () => { moveNumber: 0n, isPlayerTurn: true, iStarted: false, + error: null, }), isChannelReady: () => true, dispatch: () => { @@ -214,6 +216,7 @@ describe('Calpoker fresh hand startup', () => { moveNumber: 0n, isPlayerTurn: true, iStarted: false, + error: null, }), isChannelReady: () => true, dispatch: makeDispatch(makeMove), @@ -241,6 +244,7 @@ describe('Calpoker fresh hand startup', () => { moveNumber: 0n, isPlayerTurn: true, iStarted: false, + error: null, }), isChannelReady: () => true, dispatch: makeDispatch(makeMove), @@ -266,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; @@ -287,6 +388,7 @@ describe('Calpoker terminal hand projection', () => { moveNumber: 2n, isPlayerTurn: true, iStarted: false, + error: null, }), isChannelReady: () => true, dispatch: (intent: GameIntent) => { @@ -512,6 +614,7 @@ describe('Calpoker terminal hand projection', () => { moveNumber: 2n, isPlayerTurn: false, iStarted: false, + error: null, }), isChannelReady: () => true, dispatch, @@ -590,9 +693,7 @@ describe('Calpoker terminal hand projection', () => { renderer = create(React.createElement(Harness, { terminalOutcome: null })); }); act(() => { - const current = calpokerStateCodec.decode( - controller.handState, - )!; + const current = calpokerStateCodec.decode(controller.handState)!; const next = reduceCalpokerDurableState(current, { type: 'opponent-moved', gameId: '7', diff --git a/games/calpoker/ui/components/CaliforniaPoker.tsx b/games/calpoker/ui/components/CaliforniaPoker.tsx index 661b2796c..c90a74d73 100644 --- a/games/calpoker/ui/components/CaliforniaPoker.tsx +++ b/games/calpoker/ui/components/CaliforniaPoker.tsx @@ -58,6 +58,7 @@ const CaliforniaPoker: React.FC = ({ opponentName, terminalOutcome, interactionMode = 'live', + error, }) => { const interactive = interactionMode === 'live'; const settlementByUsFlag = terminalOutcome == null ? null : settlementByUs(terminalOutcome); @@ -592,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/games/calpoker/ui/play.tsx b/games/calpoker/ui/play.tsx index d9c29382d..1f352e966 100644 --- a/games/calpoker/ui/play.tsx +++ b/games/calpoker/ui/play.tsx @@ -130,6 +130,7 @@ export function CalpokerLiveMount(props: CalpokerLiveMountProps) { opponentName={opponentName} terminalOutcome={hand.terminalOutcome} interactionMode={handSource.interactionMode} + error={hand.error} /> ); } diff --git a/games/calpoker/ui/serialize.ts b/games/calpoker/ui/serialize.ts index 4dbae9918..e73a9c5d0 100644 --- a/games/calpoker/ui/serialize.ts +++ b/games/calpoker/ui/serialize.ts @@ -13,6 +13,11 @@ export interface CalpokerDisplaySnapshot { opponentDisplayText: string; } +export interface CalpokerError { + tag: string; + message: string; +} + export interface CalpokerHandState { playerHand: bigint[]; opponentHand: bigint[]; @@ -22,6 +27,7 @@ export interface CalpokerHandState { cardSelections?: bigint[]; displaySnapshot?: CalpokerDisplaySnapshot; outcome?: CalpokerOutcomeShape; + error: CalpokerError | null; } function isCardArray(value: unknown): value is bigint[] { @@ -69,6 +75,18 @@ function isCalpokerOutcome(value: unknown): value is CalpokerOutcomeShape; + 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; @@ -94,13 +112,14 @@ function isCalpokerHandState(value: unknown): value is CalpokerHandState { typeof state.isPlayerTurn === 'boolean' && typeof state.iStarted === 'boolean' && (state.displaySnapshot === undefined || isDisplaySnapshot(state.displaySnapshot)) && - (state.outcome === undefined || isCalpokerOutcome(state.outcome)) + (state.outcome === undefined || isCalpokerOutcome(state.outcome)) && + (state.error === null || isCalpokerError(state.error)) ); } export const calpokerStateCodec = defineGameStateCodec({ gameType: 'calpoker', - version: 2n, + version: 3n, canRemountFinished: true, isState: isCalpokerHandState, }); @@ -113,6 +132,7 @@ function initialState(isMyTurn: boolean, iStarted: boolean): CalpokerHandState { moveNumber: 0n, isPlayerTurn: isMyTurn, iStarted, + error: null, }; } @@ -241,6 +261,12 @@ export function reduceCalpokerDurableState( } 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, diff --git a/games/calpoker/ui/types/CaliforniapokerProps.ts b/games/calpoker/ui/types/CaliforniapokerProps.ts index afc21ae9b..ff06ec1c3 100644 --- a/games/calpoker/ui/types/CaliforniapokerProps.ts +++ b/games/calpoker/ui/types/CaliforniapokerProps.ts @@ -1,4 +1,5 @@ import type { GameInteractionMode, SettlementOutcome } from '../../../host'; +import type { CalpokerError } from '../serialize'; export interface CalpokerOutcomeView { my_win_outcome: 'win' | 'lose' | 'tie'; @@ -40,4 +41,5 @@ export interface CaliforniapokerProps { opponentName?: string; terminalOutcome?: SettlementOutcome | null; interactionMode?: GameInteractionMode; + error?: CalpokerError | null; } diff --git a/games/calpoker/ui/useCalpokerHand.ts b/games/calpoker/ui/useCalpokerHand.ts index d792c6262..63bd4d758 100644 --- a/games/calpoker/ui/useCalpokerHand.ts +++ b/games/calpoker/ui/useCalpokerHand.ts @@ -1,11 +1,7 @@ import { useEffect, useCallback, useRef } from 'react'; import { Program } from 'clvm-lib'; import type { CalpokerOutcomeShape } from './outcome'; -import type { - GameHandOrigin, - GameHandSource, - GameTerminalModel, -} from '../../host'; +import type { GameHandOrigin, GameHandSource, GameTerminalModel } from '../../host'; import { gameHandState, requireLiveGameHandSource } from '../../host'; import { calpokerStateCodec, @@ -28,6 +24,7 @@ export interface UseCalpokerHandResult { setHandOrder: (playerHand: bigint[], opponentHand?: bigint[]) => void; moveNumber: bigint; outcome: CalpokerOutcomeShape | undefined; + error: CalpokerHandState['error']; terminalOutcome: GameTerminalModel['outcome']; handleMakeMove: () => void; handleCheat: () => void; @@ -110,7 +107,7 @@ export function useCalpokerHand( command: LocalGameCommand, ): void => { const controller = requireLiveGameHandSource(handSourceRef.current); - const next = update(currentState()); + const next = { ...update(currentState()), error: null }; controller.dispatch( command.type === 'make-move' ? { @@ -220,9 +217,8 @@ export function useCalpokerHand( requireLiveGameHandSource(handSourceRef.current); const gid = gameIdRef.current; if (!gid) return; - // A cheat is just an (illegal) move; drive the same turn-change path a - // normal move uses so the status shows "Playing our move on-chain" while - // it lands, instead of staying on our turn. + // 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, @@ -273,6 +269,7 @@ export function useCalpokerHand( setHandOrder, moveNumber: handState.moveNumber, outcome: suppressInitialOutcomeRef.current ? undefined : handState.outcome, + error: handState.error, terminalOutcome: terminal.outcome, handleMakeMove, handleCheat, diff --git a/games/host/index.ts b/games/host/index.ts index 9b9f864d0..9553ba646 100644 --- a/games/host/index.ts +++ b/games/host/index.ts @@ -103,18 +103,6 @@ export function parseSettlementShare(value: unknown): string | null { return String(value); } -export type GameTurnState = - | 'my-turn' - | 'their-turn' - | 'playing-on-chain' - | 'replaying' - | 'opponent-illegal-move' - | 'submitting-timeout' - | 'finishing' - | 'finishing-waiting-timeout' - | 'finishing-spending' - | 'ended'; - export type GameTerminalType = | 'none' | 'settled' diff --git a/games/krunk/rust/tests/sim.rs b/games/krunk/rust/tests/sim.rs index e6eef1416..2dd17e472 100644 --- a/games/krunk/rust/tests/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/games/krunk/ui/krunk.test.ts b/games/krunk/ui/krunk.test.ts index bd1ede8c5..eebf5c9d9 100644 --- a/games/krunk/ui/krunk.test.ts +++ b/games/krunk/ui/krunk.test.ts @@ -434,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.', }); }); diff --git a/games/krunk/ui/serialize.ts b/games/krunk/ui/serialize.ts index de3804ee6..34d848348 100644 --- a/games/krunk/ui/serialize.ts +++ b/games/krunk/ui/serialize.ts @@ -303,39 +303,7 @@ export function applyKrunkMoveRejected( ): 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; + return { ...state, error: `${word} is not in the dictionary.` }; } export function reduceKrunkDurableState( diff --git a/games/spacepoker/ui/SpacePoker.tsx b/games/spacepoker/ui/SpacePoker.tsx index c8bfa6958..c0aedf8cf 100644 --- a/games/spacepoker/ui/SpacePoker.tsx +++ b/games/spacepoker/ui/SpacePoker.tsx @@ -43,13 +43,7 @@ export default function SpacePoker({ const interactive = handSource.interactionMode === 'live'; const betSizeValue = BigInt(betSize); const unitSizeMojosValue = BigInt(unitSizeMojos); - const sp = useSpacepokerHand( - handSource, - gameId, - betSizeValue, - unitSizeMojosValue, - terminal, - ); + const sp = useSpacepokerHand(handSource, gameId, betSizeValue, unitSizeMojosValue, terminal); const { handler, myTurn, N } = sp.gameState; const { currencyLabels: spCurrency, formatAmount } = useGameHost(); @@ -193,6 +187,11 @@ export default function SpacePoker({

{footerStatus}

+ {sp.error && ( +

+ {sp.error.message} +

+ )}
diff --git a/games/spacepoker/ui/serialize.ts b/games/spacepoker/ui/serialize.ts index 301699755..dccb2e2dd 100644 --- a/games/spacepoker/ui/serialize.ts +++ b/games/spacepoker/ui/serialize.ts @@ -7,6 +7,10 @@ import { } 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; @@ -52,6 +56,7 @@ export interface SpacepokerHandState { coinTossIOpen: boolean | null; unitSizeMojos: bigint; displayMode: SpacepokerDisplayMode; + error: SpacepokerError | null; } const HANDLERS = new Set([0n, 1n, 2n, 3n, 4n, 5n, 6n]); @@ -137,6 +142,18 @@ function isOutcome(value: unknown): value is SpOutcome { ); } +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; @@ -179,13 +196,14 @@ function isSpacepokerHandState(value: unknown): value is SpacepokerHandState { typeof state.unitSizeMojos === 'bigint' && state.unitSizeMojos > 0n && typeof state.displayMode === 'string' && - DISPLAY_MODES.has(state.displayMode) + DISPLAY_MODES.has(state.displayMode) && + (state.error === null || isSpacepokerError(state.error)) ); } export const spacepokerStateCodec = defineGameStateCodec({ gameType: 'spacepoker', - version: 3n, + version: 4n, canRemountFinished: true, isState: isSpacepokerHandState, }); @@ -207,6 +225,7 @@ function initialState(isMyTurn: boolean, unitSizeMojos: bigint): SpacepokerHandS coinTossIOpen: null, unitSizeMojos, displayMode: unitSizeMojos >= 1_000_000n ? 'xch' : 'mojos', + error: null, }; } @@ -533,6 +552,12 @@ export function reduceSpacepokerDurableState( ? reduceSpacepokerSettlementState(current, event.terminal.outcome) : current; } + if (event.type === 'move-rejected') { + return { + ...current, + error: { tag: event.tag, message: event.message }, + }; + } if (event.type !== 'opponent-moved' && event.type !== 'game-message') return current; const readableEvent = { type: event.type, diff --git a/games/spacepoker/ui/spacePoker.test.ts b/games/spacepoker/ui/spacePoker.test.ts index 5a0fa4d99..390976fb7 100644 --- a/games/spacepoker/ui/spacePoker.test.ts +++ b/games/spacepoker/ui/spacePoker.test.ts @@ -43,6 +43,7 @@ function handState(overrides: Partial = {}): SpacepokerHand coinTossIOpen: true, unitSizeMojos: 10n, displayMode: 'units', + error: null, ...overrides, }; } @@ -130,6 +131,67 @@ describe('Space Poker machine-owned hand state', () => { Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); }); + 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; diff --git a/games/spacepoker/ui/useSpacepokerHand.ts b/games/spacepoker/ui/useSpacepokerHand.ts index 8b1ffc981..0ae65971d 100644 --- a/games/spacepoker/ui/useSpacepokerHand.ts +++ b/games/spacepoker/ui/useSpacepokerHand.ts @@ -64,6 +64,7 @@ export interface UseSpacepokerHandResult { betUnit: bigint; handHistory: SpHandEntry[]; outcome: SpOutcome | null; + error: SpacepokerHandState['error']; terminalOutcome: SettlementOutcome | null; terminalState: SpTerminalState; lastRaise: bigint; @@ -132,7 +133,7 @@ export function useSpacepokerHand( const controller = requireLiveGameHandSource(handSourceRef.current); const id = gameIdRef.current; if (!id) return; - const next = update(currentDurableState()); + const next = { ...update(currentDurableState()), error: null }; controller.dispatch( command.type === 'make-move' ? { type: 'make-move', gameId: id, readable: command.readable, state: next } @@ -349,6 +350,7 @@ export function useSpacepokerHand( betUnit, handHistory: state.handHistory, outcome: state.outcome, + error: state.error, terminalOutcome: terminal.outcome, terminalState: state.terminalState, lastRaise: state.lastRaise, diff --git a/src/session_phases/effects.rs b/src/session_phases/effects.rs index 5cf543663..056e797da 100644 --- a/src/session_phases/effects.rs +++ b/src/session_phases/effects.rs @@ -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)] @@ -277,6 +286,10 @@ pub enum GameNotification { tag: String, message: String, }, + LocalActionApplied { + id: GameID, + action: LocalActionKind, + }, ChannelStatus(ChannelStatusSnapshot), } @@ -563,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/mod.rs b/src/session_phases/mod.rs index 4fcfa50c2..396f38269 100644 --- a/src/session_phases/mod.rs +++ b/src/session_phases/mod.rs @@ -18,7 +18,8 @@ use crate::common::types::{ }; use crate::session_phases::effects::{ format_coin, CancelReason, ChannelStatus, ChannelStatusSnapshot, CoinOfInterest, Effect, - FailedGameAction, GameNotification, GameStatusKind, GameStatusOtherParams, SettlementOutcome, + FailedGameAction, GameNotification, GameStatusKind, GameStatusOtherParams, LocalActionKind, + SettlementOutcome, }; use crate::shutdown::get_conditions_with_channel_state; use crate::utils::proper_list; @@ -137,6 +138,15 @@ 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) => { @@ -1040,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()?; @@ -1056,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 { @@ -1080,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)); } @@ -1090,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(); @@ -1247,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( @@ -1912,6 +1926,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), diff --git a/src/session_phases/on_chain.rs b/src/session_phases/on_chain.rs index b386e07b2..1d3e55aba 100644 --- a/src/session_phases/on_chain.rs +++ b/src/session_phases/on_chain.rs @@ -18,7 +18,8 @@ use crate::referee::types::{ use crate::referee::Referee; use crate::session_phases::effects::{ format_coin, ChannelStatus, ChannelStatusSnapshot, CoinOfInterest, Effect, GameNotification, - GameStatusKind, GameStatusOtherParams, SettlementOutcome, TimeoutClaimSemantic, + GameStatusKind, GameStatusOtherParams, LocalActionKind, SettlementOutcome, + TimeoutClaimSemantic, }; use crate::session_phases::types::{validate_new_move_action, GameAction, PotatoState}; @@ -102,6 +103,7 @@ pub struct OnChainPhaseArgs { fn on_chain_move_submission_effects( game_id: GameID, + action: LocalActionKind, current_coin: &CoinString, transaction: Spend, ) -> Vec { @@ -116,6 +118,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, @@ -1523,6 +1529,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() { @@ -1561,12 +1568,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)?; @@ -1595,6 +1608,7 @@ impl OnChainPhase { Ok(on_chain_move_submission_effects( game_id, + action, current_coin, transaction, )) @@ -1629,7 +1643,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()) } @@ -1652,7 +1673,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() { @@ -1681,12 +1709,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 @@ -1713,6 +1747,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, @@ -2009,12 +2047,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/simulator/tests/session_phases_sim.rs b/src/simulator/tests/session_phases_sim.rs index 05752c2be..657babe03 100644 --- a/src/simulator/tests/session_phases_sim.rs +++ b/src/simulator/tests/session_phases_sim.rs @@ -18,7 +18,7 @@ use crate::common::types::{ 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; @@ -3989,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() @@ -4207,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!( @@ -4271,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!( diff --git a/wasm/contract.d.ts b/wasm/contract.d.ts index b0fc6b7d4..8c18cca38 100644 --- a/wasm/contract.d.ts +++ b/wasm/contract.d.ts @@ -174,7 +174,7 @@ export interface InsufficientBalancePayload { export interface ActionFailedPayload { id?: bigint; - action?: 'make_move' | 'accept_settlement'; + action?: 'make_move' | 'accept_settlement' | 'cheat'; reason: string; } @@ -184,6 +184,11 @@ export interface MoveRejectedPayload { message: string; } +export interface LocalActionAppliedPayload { + id: bigint; + action: 'make_move' | 'accept_settlement' | 'cheat'; +} + export interface WasmNotificationMap { ChannelStatus: ChannelStatusPayload; GameStatus: GameStatusPayload; @@ -194,6 +199,7 @@ export interface WasmNotificationMap { InsufficientBalance: InsufficientBalancePayload; MoveRejected: MoveRejectedPayload; ActionFailed: ActionFailedPayload; + LocalActionApplied: LocalActionAppliedPayload; } export type WasmNotification = { diff --git a/wasm/src/mod.rs b/wasm/src/mod.rs index 364b8e68f..d00f772b0 100644 --- a/wasm/src/mod.rs +++ b/wasm/src/mod.rs @@ -914,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] From f37551ad60498510fd65c14178597bb55d5b8f15 Mon Sep 17 00:00:00 2001 From: Bram Cohen Date: Fri, 21 Aug 2026 17:40:45 -0700 Subject: [PATCH 08/12] Persist rejected actions and restore turn alerts. --- front-end/src/lib/gameTabAttention.ts | 2 +- front-end/src/lib/session/sessionMachineGame.ts | 9 +++++++-- front-end/src/lib/tests/game_tab_attention.test.ts | 7 ++++--- .../src/lib/tests/session_machine_interpreter.test.ts | 5 ++++- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/front-end/src/lib/gameTabAttention.ts b/front-end/src/lib/gameTabAttention.ts index 15837a33e..ae0b6032f 100644 --- a/front-end/src/lib/gameTabAttention.ts +++ b/front-end/src/lib/gameTabAttention.ts @@ -19,7 +19,7 @@ export function gameModelNeedsGameTabAttention( before.presentation !== instance.presentation && (instance.presentation === 'off-chain-my-turn' || instance.presentation === 'on-chain-my-turn'); - if (becameOurTurn && previous.handState !== current.handState) return true; + if (becameOurTurn) return true; if ( before?.terminal.outcome !== instance.terminal.outcome && diff --git a/front-end/src/lib/session/sessionMachineGame.ts b/front-end/src/lib/session/sessionMachineGame.ts index 9df96e061..f3db23b3b 100644 --- a/front-end/src/lib/session/sessionMachineGame.ts +++ b/front-end/src/lib/session/sessionMachineGame.ts @@ -258,8 +258,8 @@ export function reduceDurableGameEvent( effects: [{ type: 'controller-set-last-outcome', outcomeWin }, { type: 'persist-session' }], }; } - case 'notification-move-rejected': - return withGameInput( + case 'notification-move-rejected': { + const transition = withGameInput( { ...state, model: withoutPendingIds(state.model, [event.id]) }, { type: 'move-rejected', @@ -268,6 +268,11 @@ export function reduceDurableGameEvent( message: event.message, }, ); + return { + ...transition, + effects: [...transition.effects, { type: 'persist-session' }], + }; + } case 'notification-insufficient-balance': { const proposal = selectProposalGroupByMemberId(state.model, event.id); if (!proposal) { diff --git a/front-end/src/lib/tests/game_tab_attention.test.ts b/front-end/src/lib/tests/game_tab_attention.test.ts index 826cb72dd..8b1739698 100644 --- a/front-end/src/lib/tests/game_tab_attention.test.ts +++ b/front-end/src/lib/tests/game_tab_attention.test.ts @@ -29,11 +29,12 @@ function gameWith( } describe('gameTabAttention', () => { - it('marks opponent moves as attention', () => { + it('marks becoming our turn as attention without requiring a hand-state change', () => { + const handState = {}; expect( gameModelNeedsGameTabAttention( - gameWith('off-chain-their-turn', {}), - gameWith('off-chain-my-turn', {}), + gameWith('off-chain-their-turn', handState), + gameWith('off-chain-my-turn', handState), ), ).toBe(true); }); diff --git a/front-end/src/lib/tests/session_machine_interpreter.test.ts b/front-end/src/lib/tests/session_machine_interpreter.test.ts index be9dde93e..aa7b99132 100644 --- a/front-end/src/lib/tests/session_machine_interpreter.test.ts +++ b/front-end/src/lib/tests/session_machine_interpreter.test.ts @@ -1020,7 +1020,7 @@ describe('session machine local game action boundary', () => { }); it('denies duplicate pending actions and reduces delayed rejection on canonical state', () => { - const { runtime } = localActionHarness(jest.fn(() => 'queued' as const)); + const { runtime, persisted } = localActionHarness(jest.fn(() => 'queued' as const)); const current = calpokerStateCodec.decode(runtime.getState().model.game.handState)!; const request = { gameType: 'calpoker' as const, @@ -1045,6 +1045,9 @@ describe('session machine local game action boundary', () => { error: { tag: 'invalid', message: 'Try another move' }, }), ); + expect(persisted).toHaveLength(2); + expect(persisted[1].model.game.pendingCandidates).toEqual({}); + expect(persisted[1].model.game.handState).toEqual(runtime.getState().model.game.handState); }); it('discards a matching delayed cheat failure while retaining shared error UX', () => { From f6542250cd3e0eb83e15505416000efa2ce91615 Mon Sep 17 00:00:00 2001 From: Bram Cohen Date: Fri, 21 Aug 2026 17:57:48 -0700 Subject: [PATCH 09/12] Make lifecycle phase behavior explicit. --- OVERVIEW.md | 12 +- src/game_session.rs | 372 +++++++----------- src/session_phases/handshake_initiator.rs | 128 +++++- src/session_phases/handshake_receiver.rs | 125 +++++- src/session_phases/mod.rs | 104 ++++- src/session_phases/on_chain.rs | 174 +++++++- .../spend_channel_coin_phase.rs | 179 ++++++++- src/test_support/peer/peer_harness.rs | 14 +- 8 files changed, 822 insertions(+), 286 deletions(-) diff --git a/OVERVIEW.md b/OVERVIEW.md index e741323a7..4948b5172 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -532,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 ─┐ diff --git a/src/game_session.rs b/src/game_session.rs index 6d6270aa6..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 { @@ -571,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(), @@ -600,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)] @@ -642,14 +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) + self.peer + .force_stale_unroll_spend_for_testing(&mut env, saved) } /// Render the current protocol-level peer state as indented text for the @@ -871,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() @@ -969,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(()) } @@ -1003,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(()) } @@ -1431,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); @@ -1456,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); @@ -1727,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) } } @@ -1965,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/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 396f38269..0485c2997 100644 --- a/src/session_phases/mod.rs +++ b/src/session_phases/mod.rs @@ -19,12 +19,12 @@ use crate::common::types::{ use crate::session_phases::effects::{ format_coin, CancelReason, ChannelStatus, ChannelStatusSnapshot, CoinOfInterest, Effect, FailedGameAction, GameNotification, GameStatusKind, GameStatusOtherParams, LocalActionKind, - SettlementOutcome, + 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, @@ -1766,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<'_>, @@ -1849,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<'_>, @@ -1914,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) + } + #[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 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/on_chain.rs b/src/session_phases/on_chain.rs index 1d3e55aba..57c2fc494 100644 --- a/src/session_phases/on_chain.rs +++ b/src/session_phases/on_chain.rs @@ -3,24 +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::{ 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, LocalActionKind, SettlementOutcome, + 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; @@ -1900,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) } @@ -1976,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() { @@ -2033,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", + )) + } + #[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 as_any_mut(&mut self) -> &mut dyn std::any::Any { - self + fn get_game_coin(&self, game_id: &GameID) -> Option { + OnChainPhase::get_game_coin(self, game_id) } } diff --git a/src/session_phases/spend_channel_coin_phase.rs b/src/session_phases/spend_channel_coin_phase.rs index 99567ca78..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, }; @@ -1126,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<'_>, @@ -1186,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<'_>, @@ -1196,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, @@ -1325,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 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) } - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self + #[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 } } @@ -1508,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/test_support/peer/peer_harness.rs b/src/test_support/peer/peer_harness.rs index f29ba95ca..fe9da5c77 100644 --- a/src/test_support/peer/peer_harness.rs +++ b/src/test_support/peer/peer_harness.rs @@ -376,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)] @@ -522,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(), From 0ea4cbadfc22fb3f2d9f0a9305845423f60e67d9 Mon Sep 17 00:00:00 2001 From: Bram Cohen Date: Fri, 21 Aug 2026 18:27:58 -0700 Subject: [PATCH 10/12] Fix Space Poker per-player stack limits. --- games/spacepoker/ui/play.tsx | 2 +- games/spacepoker/ui/spacePoker.test.ts | 36 ++++++++++++++++++++++++ games/spacepoker/ui/useSpacepokerHand.ts | 2 +- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/games/spacepoker/ui/play.tsx b/games/spacepoker/ui/play.tsx index 91d7f8d93..5f8ebdeae 100644 --- a/games/spacepoker/ui/play.tsx +++ b/games/spacepoker/ui/play.tsx @@ -38,7 +38,7 @@ export function SpacepokerLiveMount(props: SpacepokerLiveMountProps) { throw new Error('Space Poker mount requires initialized durable game state'); } const unitSizeMojosValue = handState.unitSizeMojos; - const stackSize = betSize / unitSizeMojosValue; + const stackSize = betSize / 2n / unitSizeMojosValue; const handleGameLog = useCallback( (lines: string[]) => { if (!appendGameLog) return; diff --git a/games/spacepoker/ui/spacePoker.test.ts b/games/spacepoker/ui/spacePoker.test.ts index 390976fb7..90b1064cf 100644 --- a/games/spacepoker/ui/spacePoker.test.ts +++ b/games/spacepoker/ui/spacePoker.test.ts @@ -131,6 +131,42 @@ describe('Space Poker machine-owned hand state', () => { 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('preserves delayed canonical gameplay state and displays the rejection', () => { const current = handState(); const next = reduceSpacepokerDurableState(current, { diff --git a/games/spacepoker/ui/useSpacepokerHand.ts b/games/spacepoker/ui/useSpacepokerHand.ts index 0ae65971d..e713afc2a 100644 --- a/games/spacepoker/ui/useSpacepokerHand.ts +++ b/games/spacepoker/ui/useSpacepokerHand.ts @@ -108,7 +108,7 @@ export function useSpacepokerHand( const interactive = handSource.interactionMode === 'live'; const betUnit = state.unitSizeMojos; - const stackSize = betSize / betUnit; + 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); From ba71a74e426818a68db6447feb237f4239dc296f Mon Sep 17 00:00:00 2001 From: Bram Cohen Date: Fri, 21 Aug 2026 18:41:22 -0700 Subject: [PATCH 11/12] Fix terminal Space Poker stack logs. --- games/spacepoker/ui/SpacePoker.tsx | 2 +- games/spacepoker/ui/spacePoker.test.ts | 32 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/games/spacepoker/ui/SpacePoker.tsx b/games/spacepoker/ui/SpacePoker.tsx index c0aedf8cf..3818bd1c0 100644 --- a/games/spacepoker/ui/SpacePoker.tsx +++ b/games/spacepoker/ui/SpacePoker.tsx @@ -59,7 +59,7 @@ 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; + const stackSize = sp.betUnit > 0n ? betSizeValue / 2n / sp.betUnit : 0n; onGameLog?.( formatSpacepokerHandLog( sp.playerHoleCards, diff --git a/games/spacepoker/ui/spacePoker.test.ts b/games/spacepoker/ui/spacePoker.test.ts index 90b1064cf..92b7c01b4 100644 --- a/games/spacepoker/ui/spacePoker.test.ts +++ b/games/spacepoker/ui/spacePoker.test.ts @@ -167,6 +167,38 @@ describe('Space Poker machine-owned hand state', () => { 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, { From 90e16150e837129528af50e79da2a9c11617f301 Mon Sep 17 00:00:00 2001 From: Bram Cohen Date: Fri, 21 Aug 2026 19:08:16 -0700 Subject: [PATCH 12/12] Harden game package boundaries. --- GAME_WRITING_GUIDE.md | 56 ++++- front-end/scripts/generate-game-registry.mjs | 14 +- .../src/components/GameProposalDialogs.tsx | 15 +- front-end/src/generated/gamePackages.ts | 14 +- front-end/src/lib/gameProposalCodec.ts | 15 +- front-end/src/lib/gameRegistry.ts | 57 ++--- front-end/src/lib/session/incomingProposal.ts | 3 +- .../session/sessionMachineNotifications.ts | 2 +- front-end/src/lib/tests/game_adapters.test.ts | 220 ++++++++++++++---- .../lib/tests/game_package_isolation.test.ts | 11 + .../tests/session_machine.proposals.test.ts | 19 ++ .../tests/session_machine_interpreter.test.ts | 25 +- games/calpoker/ui/handProposal.ts | 9 +- games/host/index.ts | 153 ++++++++++-- games/spacepoker/ui/handProposal.ts | 9 +- 15 files changed, 489 insertions(+), 133 deletions(-) diff --git a/GAME_WRITING_GUIDE.md b/GAME_WRITING_GUIDE.md index c04c9f549..c85f50456 100644 --- a/GAME_WRITING_GUIDE.md +++ b/GAME_WRITING_GUIDE.md @@ -64,6 +64,12 @@ file has a conventional export that the generator discovers: - `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): @@ -95,6 +101,14 @@ 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 @@ -217,8 +231,8 @@ The same registration translates between a `HandProposal` and CLVM: 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)` reconstructs and validates the proposal - received from the peer. +- `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. @@ -245,10 +259,20 @@ interface FactoryParameterCodec { 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): HandProposal | null; + decodeHandProposal( + base: HandProposalBase, + params: TParams, + context: HandProposalDecodeContext, + ): HandProposal | null; } ``` @@ -263,14 +287,18 @@ Decoding is intentionally two-stage: 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)` combines the already-decoded common terms - with the typed parameters. It must reject contradictions between duplicated - values, add the package's `gameType` and game-specific proposal fields, run - the complete proposal validation, and return `null` on any mismatch. +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. +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: @@ -281,11 +309,12 @@ readClvmFlag(program: Program): boolean | null; readClvmList(program: Program, length: number): readonly Program[] | null; ``` -- `readClvmProgram` accepts only a `Uint8Array` containing one deserializable - program. +- `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 list with exactly `length` members. +- `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 @@ -438,6 +467,11 @@ input)` must preserve already-initialized member state. `iStarted` identifies - `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 diff --git a/front-end/scripts/generate-game-registry.mjs b/front-end/scripts/generate-game-registry.mjs index 9df781ed4..5e9fc30a6 100644 --- a/front-end/scripts/generate-game-registry.mjs +++ b/front-end/scripts/generate-game-registry.mjs @@ -31,6 +31,10 @@ 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]`; @@ -49,13 +53,13 @@ const imports = production `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} = Object.assign({}, handProposal${index}, { HandProposalForm: HandProposalForm${index}, ...play${index} });`, + `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 packageList = tsArray(production.map((_, index) => `pkg${index}`)); +const packageMap = production.map((key, index) => ` ${tsProperty(key)}: pkg${index},`).join('\n'); const destDir = join(FE, '../src/generated'); mkdirSync(destDir, { recursive: true }); @@ -77,11 +81,15 @@ 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 = ${packageList}; +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'; `, ); diff --git a/front-end/src/components/GameProposalDialogs.tsx b/front-end/src/components/GameProposalDialogs.tsx index abc77c709..e44751454 100644 --- a/front-end/src/components/GameProposalDialogs.tsx +++ b/front-end/src/components/GameProposalDialogs.tsx @@ -19,7 +19,6 @@ export function ComposeProposalDialog({ const compose = session.composeDraftState; const pkg = packageFor(compose.selectedGame); const canSubmit = composeDraftCanSubmit(compose, maxPerHandMojos); - const Editor = pkg.HandProposalForm; const submit = () => { if (!canSubmit) return; @@ -45,13 +44,13 @@ export function ComposeProposalDialog({ ))}
- session.updateSelectedComposeDraft(update)} - onSubmit={submit} - /> + {pkg.renderHandProposalForm({ + draft: composeDraftValue(compose, compose.selectedGame), + disabled: session.composeProposalSent, + maxPerHandMojos, + onChange: (update) => session.updateSelectedComposeDraft(update), + onSubmit: submit, + })}
).Timeout @@ -52,13 +49,16 @@ function catalogTypeFromPayload( export function decodeProposalMadeTerms( payload: ProposalMadePayload, + iStarted: boolean, gameTypeOverride?: RegisteredGameType, ): HandProposal | null { const mine = parseAmount(payload.my_contribution); const theirs = parseAmount(payload.their_contribution); const resolvedType = catalogTypeFromPayload(payload, gameTypeOverride); const timeout = parseTimeout(payload.timeout); - if (!mine || !theirs || !resolvedType || timeout == null) return null; + if (!mine || !theirs || !resolvedType || timeout == null || payload.parameters == null) { + return null; + } try { return decodeHandProposal( resolvedType, @@ -67,7 +67,8 @@ export function decodeProposalMadeTerms( theirContribution: BigInt(theirs), gameTimeout: timeout, }, - coerceParameterState(payload.parameters ?? payload.initial_state), + coerceParameterState(payload.parameters), + { iStarted, origin: 'peer' }, ); } catch { return null; diff --git a/front-end/src/lib/gameRegistry.ts b/front-end/src/lib/gameRegistry.ts index 75784bba7..36a23a45b 100644 --- a/front-end/src/lib/gameRegistry.ts +++ b/front-end/src/lib/gameRegistry.ts @@ -1,10 +1,11 @@ -import { GENERATED_GAME_PACKAGES } from '../generated/gamePackages'; +import { GENERATED_GAME_PACKAGES, GENERATED_GAME_PACKAGES_BY_KEY } from '../generated/gamePackages'; import type { CatalogGameType } from '../generated/gamePresets'; import type { ComposeDraftValue, GameInput, - GamePackage, + HandProposalDecodeContext, HandProposal as HostHandProposal, + RegisteredGamePackage, SavedHandProposalExtras, } from '@games/host'; import type { GameStateCodec, PersistedGameState } from './session/gameStateCodec'; @@ -15,19 +16,10 @@ import { formatMojos } from '../util'; export type { CatalogGameType } from '../generated/gamePresets'; export type RegisteredGameType = CatalogGameType; -const packagesByCatalog = new Map(); +export const GAME_PACKAGES: readonly RegisteredGamePackage[] = GENERATED_GAME_PACKAGES; -for (const pkg of GENERATED_GAME_PACKAGES) { - packagesByCatalog.set(pkg.gameType, pkg as unknown as GamePackage); -} - -export const GAME_PACKAGES: readonly GamePackage[] = - GENERATED_GAME_PACKAGES as unknown as GamePackage[]; - -export function packageFor(gameType: CatalogGameType): GamePackage { - const pkg = packagesByCatalog.get(gameType); - if (!pkg) throw new Error(`Unsupported game package: ${gameType}`); - return pkg; +export function packageFor(gameType: CatalogGameType): RegisteredGamePackage { + return GENERATED_GAME_PACKAGES_BY_KEY[gameType]; } export function gameDisplayName(gameType: CatalogGameType): string { @@ -36,7 +28,10 @@ export function gameDisplayName(gameType: CatalogGameType): string { /** Catalog names only (`calpoker`, …). Saves and mounts use this — hashes are garbled. */ export function isCatalogGameType(value: unknown): value is CatalogGameType { - return typeof value === 'string' && packagesByCatalog.has(value); + return ( + typeof value === 'string' && + Object.prototype.hasOwnProperty.call(GENERATED_GAME_PACKAGES_BY_KEY, value) + ); } export const REGISTERED_GAMES = GAME_PACKAGES.map((pkg) => { @@ -102,7 +97,7 @@ export function canRemountFinishedGameState(value: unknown): boolean { } function handProposalWithCatalogType( - registration: GamePackage, + registration: RegisteredGamePackage, handProposal: HostHandProposal | null, ): HandProposal | null { if (handProposal === null) return null; @@ -121,12 +116,18 @@ export function decodeHandProposal( gameType: RegisteredGameType, base: HandProposalBase, parameterState: unknown, + context: Pick, ): HandProposal | null { const registration = packageFor(gameType); - const params = registration.factoryParameters.decode(parameterState); - return params === null - ? null - : handProposalWithCatalogType(registration, registration.decodeHandProposal(base, params)); + const proposerStarted = context.origin === 'local' ? context.iStarted : !context.iStarted; + const decodeContext: HandProposalDecodeContext = { + ...context, + expectedSenderGoesFirst: registration.lifecycle.proposalSenderGoesFirst(proposerStarted), + }; + return handProposalWithCatalogType( + registration, + registration.decodeHandProposal(base, parameterState, decodeContext), + ); } export function validateHandProposal(handProposal: HandProposal): boolean { @@ -152,13 +153,15 @@ export function reduceRegisteredGameState( current !== null && current.gameType === gameType ? registration.stateCodec.decode(current) : null; - const next = - input.type === 'hand-started' - ? registration.durableState.initialize(decoded, input) - : decoded === null - ? null - : registration.durableState.reduceInput(decoded, input); - if (next === null) return current; + let next: unknown; + if (input.type === 'hand-started') { + next = registration.durableState.initialize(decoded, input); + } else { + if (decoded === null) { + throw new Error(`Internal ${gameType} ${input.type} input requires valid hand state`); + } + next = registration.durableState.reduceInput(decoded, input); + } if (!registration.stateCodec.isState(next)) { throw new Error(`Internal ${gameType} reducer produced invalid feature state`); } diff --git a/front-end/src/lib/session/incomingProposal.ts b/front-end/src/lib/session/incomingProposal.ts index 99a2ecffc..e96290c2a 100644 --- a/front-end/src/lib/session/incomingProposal.ts +++ b/front-end/src/lib/session/incomingProposal.ts @@ -4,9 +4,10 @@ import type { ProposalGroupModel } from './types'; export function proposalGroupFromProposalMade( payload: ProposalMadePayload | undefined, + iStarted: boolean, ): ProposalGroupModel | null { if (!payload) return null; - const terms = decodeProposalMadeTerms(payload); + const terms = decodeProposalMadeTerms(payload, iStarted); const memberIds = Array.isArray(payload.group_ids) ? payload.group_ids.map(String) : []; if (!terms || payload.id == null || memberIds.length === 0) return null; return { diff --git a/front-end/src/lib/session/sessionMachineNotifications.ts b/front-end/src/lib/session/sessionMachineNotifications.ts index dc918219b..ba71fde64 100644 --- a/front-end/src/lib/session/sessionMachineNotifications.ts +++ b/front-end/src/lib/session/sessionMachineNotifications.ts @@ -136,7 +136,7 @@ export function reduceSessionNotification( } if ('ProposalMade' in notification) { - const incoming = proposalGroupFromProposalMade(notification.ProposalMade); + const incoming = proposalGroupFromProposalMade(notification.ProposalMade, iStarted); if (!incoming) { effects.push({ type: 'controller-go-on-chain' }); return { state: current, effects }; diff --git a/front-end/src/lib/tests/game_adapters.test.ts b/front-end/src/lib/tests/game_adapters.test.ts index 1e1e6eb2f..a7d38cabe 100644 --- a/front-end/src/lib/tests/game_adapters.test.ts +++ b/front-end/src/lib/tests/game_adapters.test.ts @@ -1,4 +1,5 @@ import { Program } from 'clvm-lib'; +import { EMPTY_GAME_TERMINAL_MODEL, type GameInput } from '@games/host'; import calpokerPackage from '@games/calpoker/ui/handProposal'; import { calpokerRegistration } from '@games/calpoker/ui/handProposal'; import { krunkRegistration, isValidKrunkStake } from '@games/krunk/ui/handProposal'; @@ -10,6 +11,7 @@ import { encodeHandProposalExtras, handProposalsEqual, packageFor, + reduceRegisteredGameState, REGISTERED_GAMES, isCatalogGameType, validateHandProposal, @@ -30,6 +32,8 @@ const base = { theirContribution: 100n, gameTimeout: 15n, }; +const peerSenderSecond = { iStarted: false, origin: 'peer' as const }; +const peerSenderFirst = { iStarted: true, origin: 'peer' as const }; describe('pure game registrations', () => { it('derives display metadata from the keyed registration source', () => { @@ -94,6 +98,29 @@ describe('pure game registrations', () => { calpokerRegistration.factoryParameters.encode(calParams).serialize(), ), ).toBeNull(); + const canonicalCal = calpokerRegistration.factoryParameters.encode(calParams).serialize(); + expect( + calpokerRegistration.factoryParameters.decode(Uint8Array.from([...canonicalCal, 0x80])), + ).toBeNull(); + expect( + calpokerRegistration.factoryParameters.decode( + Program.cons( + Program.fromBigInt(100n), + Program.cons(Program.fromBigInt(0n), Program.fromBigInt(2n)), + ).serialize(), + ), + ).toBeNull(); + expect( + spacepokerRegistration.factoryParameters.decode( + Program.cons( + Program.fromBigInt(100n), + Program.cons( + Program.fromBigInt(10n), + Program.cons(Program.fromBigInt(0n), Program.fromBigInt(2n)), + ), + ).serialize(), + ), + ).toBeNull(); const formatMojos = (mojos: bigint) => `${mojos} MOJO`; expect( calpokerRegistration.describeHandProposal({ gameType: 'calpoker', ...base }, { formatMojos }), @@ -130,22 +157,73 @@ describe('pure game registrations', () => { true, ).serialize(); const krunk = encodeGameProposalParameters({ gameType: 'krunk', ...base }, true).serialize(); - expect(decodeHandProposal('calpoker', base, cal)).toEqual({ gameType: 'calpoker', ...base }); - expect(decodeHandProposal('spacepoker', base, space)).toEqual({ + expect(decodeHandProposal('calpoker', base, cal, peerSenderSecond)).toEqual({ + gameType: 'calpoker', + ...base, + }); + expect(decodeHandProposal('spacepoker', base, space, peerSenderSecond)).toEqual({ gameType: 'spacepoker', ...base, unitSizeMojos: 10n, }); - expect(decodeHandProposal('krunk', base, krunk)).toEqual({ gameType: 'krunk', ...base }); - expect(decodeHandProposal('calpoker', base, space)).toBeNull(); - expect(decodeHandProposal('calpoker', base, krunk)).toBeNull(); - expect(decodeHandProposal('spacepoker', base, cal)).toBeNull(); - expect(decodeHandProposal('spacepoker', base, krunk)).toBeNull(); - expect(decodeHandProposal('krunk', base, cal)).toBeNull(); - expect(decodeHandProposal('krunk', base, space)).toBeNull(); - expect(decodeHandProposal('spacepoker', base, undefined)).toBeNull(); - expect(decodeHandProposal('spacepoker', base, Program.fromBigInt(10n).serialize())).toBeNull(); - expect(decodeHandProposal('spacepoker', base, Program.fromList([]).serialize())).toBeNull(); + expect(decodeHandProposal('krunk', base, krunk, peerSenderSecond)).toEqual({ + gameType: 'krunk', + ...base, + }); + expect(decodeHandProposal('calpoker', base, space, peerSenderSecond)).toBeNull(); + expect(decodeHandProposal('calpoker', base, krunk, peerSenderSecond)).toBeNull(); + expect(decodeHandProposal('spacepoker', base, cal, peerSenderSecond)).toBeNull(); + expect(decodeHandProposal('spacepoker', base, krunk, peerSenderSecond)).toBeNull(); + expect(decodeHandProposal('krunk', base, cal, peerSenderSecond)).toBeNull(); + expect(decodeHandProposal('krunk', base, space, peerSenderSecond)).toBeNull(); + expect(decodeHandProposal('spacepoker', base, undefined, peerSenderSecond)).toBeNull(); + expect( + decodeHandProposal('spacepoker', base, Program.fromBigInt(10n).serialize(), peerSenderSecond), + ).toBeNull(); + expect( + decodeHandProposal('spacepoker', base, Program.fromList([]).serialize(), peerSenderSecond), + ).toBeNull(); + }); + + it('only permits hand-started to initialize missing durable game state', () => { + const handProposal = { gameType: 'calpoker' as const, ...base }; + expect( + reduceRegisteredGameState('calpoker', null, { + type: 'hand-started', + init: { + id: '7', + gameIds: ['7'], + iStarted: true, + canAct: true, + origin: 'local', + handProposal, + }, + }), + ).not.toBeNull(); + + const impossibleInputs: Exclude[] = [ + { + type: 'opponent-moved', + gameId: '7', + readable: new Uint8Array(), + moverShare: '0', + }, + { type: 'game-message', gameId: '7', readable: new Uint8Array() }, + { type: 'move-rejected', gameId: '7', tag: 'invalid_move', message: 'invalid' }, + { type: 'hand-ended', gameId: '7', terminal: EMPTY_GAME_TERMINAL_MODEL }, + ]; + for (const input of impossibleInputs) { + expect(() => reduceRegisteredGameState('calpoker', null, input)).toThrow( + `Internal calpoker ${input.type} input requires valid hand state`, + ); + } + expect(() => + reduceRegisteredGameState( + 'calpoker', + { gameType: 'calpoker', version: -1n, state: {} }, + impossibleInputs[0], + ), + ).toThrow('requires valid hand state'); }); it('keeps package keys in the model after protocol identities are ready', () => { @@ -167,7 +245,7 @@ describe('pure game registrations', () => { 'krunk', ]); const cal = encodeGameProposalParameters({ gameType: 'calpoker', ...base }, true).serialize(); - expect(decodeHandProposal('calpoker', base, cal)).toEqual({ + expect(decodeHandProposal('calpoker', base, cal, peerSenderSecond)).toEqual({ gameType: 'calpoker', ...base, }); @@ -191,6 +269,7 @@ describe('pure game registrations', () => { 'calpoker', { ...base, theirContribution: 200n }, encodeGameProposalParameters({ gameType: 'calpoker', ...base }, true).serialize(), + peerSenderSecond, ), ).toBeNull(); expect( @@ -244,6 +323,7 @@ describe('pure game registrations', () => { Program.fromBigInt(invalid.myContribution), Program.fromBigInt(1n), ]).serialize(), + peerSenderFirst, ), ).toBeNull(); expect(decodePersistedHandProposal('calpoker', invalid, {})).toBeNull(); @@ -268,7 +348,7 @@ describe('pure game registrations', () => { Program.fromBigInt(1n), ]).serialize() : new Uint8Array([0xff]); - expect(decodeHandProposal('spacepoker', invalid, parameterState)).toBeNull(); + expect(decodeHandProposal('spacepoker', invalid, parameterState, peerSenderFirst)).toBeNull(); expect( decodePersistedHandProposal('spacepoker', invalid, { spacepoker_unit_size: unit }), ).toBeNull(); @@ -317,37 +397,93 @@ describe('pure game registrations', () => { }; const parameters = encodeGameProposalParameters(terms, true).serialize(); expect( - decodeProposalMadeTerms({ - id: '1', - group_ids: ['1', '3'], - my_contribution: { Amount: '300' }, - their_contribution: { Amount: '300' }, - timeout: 15, - game_type: BOUND_IDS[2].id, - parameters, - }), + decodeProposalMadeTerms( + { + id: '1', + group_ids: ['1', '3'], + my_contribution: { Amount: '300' }, + their_contribution: { Amount: '300' }, + timeout: 15, + game_type: BOUND_IDS[2].id, + parameters, + }, + true, + ), ).toEqual(terms); expect( - decodeProposalMadeTerms({ - id: '1', - group_ids: ['1', '3'], - my_contribution: '300', - their_contribution: '300', - timeout: { Timeout: '15' }, - game_type: BOUND_IDS[2].id, - initial_state: parameters, - }), - ).toEqual(terms); + decodeProposalMadeTerms( + { + id: '1', + group_ids: ['1', '3'], + my_contribution: '300', + their_contribution: '300', + timeout: { Timeout: '15' }, + game_type: BOUND_IDS[2].id, + initial_state: parameters, + }, + true, + ), + ).toBeNull(); + expect( + decodeProposalMadeTerms( + { + id: '1', + group_ids: ['1', '3'], + my_contribution: '300', + their_contribution: '300', + game_type: BOUND_IDS[2].id, + parameters, + }, + true, + ), + ).toBeNull(); + expect( + decodeProposalMadeTerms( + { + id: '1', + group_ids: ['1', '3'], + my_contribution: '300', + their_contribution: '300', + timeout: 15, + game_type: BOUND_IDS[2].id, + parameters: Program.fromList([]).serialize(), + }, + true, + ), + ).toBeNull(); + expect( + decodeProposalMadeTerms( + { + id: '4', + group_ids: ['4'], + my_contribution: '100', + their_contribution: '100', + timeout: 15, + game_type: BOUND_IDS[0].id, + parameters: encodeGameProposalParameters( + { gameType: 'calpoker', ...base }, + false, + ).serialize(), + }, + true, + ), + ).toEqual({ gameType: 'calpoker', ...base }); expect( - decodeProposalMadeTerms({ - id: '1', - group_ids: ['1', '3'], - my_contribution: '300', - their_contribution: '300', - timeout: 15, - game_type: BOUND_IDS[2].id, - parameters: Program.fromList([]).serialize(), - }), + decodeProposalMadeTerms( + { + id: '4', + group_ids: ['4'], + my_contribution: '100', + their_contribution: '100', + timeout: 15, + game_type: BOUND_IDS[0].id, + parameters: encodeGameProposalParameters( + { gameType: 'calpoker', ...base }, + false, + ).serialize(), + }, + false, + ), ).toBeNull(); } finally { resetProtocolIds(); diff --git a/front-end/src/lib/tests/game_package_isolation.test.ts b/front-end/src/lib/tests/game_package_isolation.test.ts index 152716ed6..f3168d14d 100644 --- a/front-end/src/lib/tests/game_package_isolation.test.ts +++ b/front-end/src/lib/tests/game_package_isolation.test.ts @@ -13,6 +13,17 @@ function walk(dir: string, files: string[] = []): string[] { } describe('game package isolation', () => { + it('assembles generated packages through the typed keyed boundary', () => { + const generated = fs.readFileSync( + path.resolve(__dirname, '../../generated/gamePackages.ts'), + 'utf8', + ); + expect(generated).toContain('defineGamePackage('); + expect(generated).toContain('GENERATED_GAME_PACKAGES_BY_KEY'); + expect(generated).not.toContain('Object.assign'); + expect(generated).not.toContain('as unknown as GamePackage'); + }); + it('does not import this player app from game UI or game tests', () => { const keys = fs.readdirSync(GAMES_ROOT).filter((name) => { const ui = path.join(GAMES_ROOT, name, 'ui'); diff --git a/front-end/src/lib/tests/session_machine.proposals.test.ts b/front-end/src/lib/tests/session_machine.proposals.test.ts index b2736ad63..6c8db87d9 100644 --- a/front-end/src/lib/tests/session_machine.proposals.test.ts +++ b/front-end/src/lib/tests/session_machine.proposals.test.ts @@ -280,6 +280,25 @@ describe('session machine behavior sequences', () => { }); expect(unreadable.effects.map((effect) => effect.type)).toContain('controller-go-on-chain'); + const missingParameters = reduceSessionMachine(state, { + type: 'wasm-notification', + notification: { + ProposalMade: { + id: '9', + group_ids: ['9'], + my_contribution: '100', + their_contribution: '100', + timeout: '15', + game_type: testProtocolId('spacepoker'), + initial_state: encodeGameProposalParameters(terms, true).serialize(), + }, + }, + iStarted: false, + }); + expect(missingParameters.effects.map((effect) => effect.type)).toContain( + 'controller-go-on-chain', + ); + const readable = reduceSessionMachine(state, { type: 'wasm-notification', notification: { diff --git a/front-end/src/lib/tests/session_machine_interpreter.test.ts b/front-end/src/lib/tests/session_machine_interpreter.test.ts index aa7b99132..31e5cc021 100644 --- a/front-end/src/lib/tests/session_machine_interpreter.test.ts +++ b/front-end/src/lib/tests/session_machine_interpreter.test.ts @@ -17,7 +17,7 @@ import type { SessionMachineEvent } from '../session/sessionMachineTypes'; import { krunkStateCodec } from '@games/krunk/ui/serialize'; import { calpokerStateCodec } from '@games/calpoker/ui/serialize'; import { spacepokerStateCodec } from '@games/spacepoker/ui/serialize'; -import { projectRegisteredPendingCandidates } from '../gameRegistry'; +import { projectRegisteredPendingCandidates, reduceRegisteredGameState } from '../gameRegistry'; import { wasmResult } from './message_protocol.harness'; const TERMS = { @@ -341,6 +341,17 @@ describe('session machine causal sequences', () => { const pending: Array<(coinHex: string | null) => void> = []; const persisted: ReturnType[] = []; const controller = fakeController({ clearDerivedGamePresentation: jest.fn() }); + const handState = reduceRegisteredGameState('calpoker', null, { + type: 'hand-started', + init: { + id: '7', + gameIds: ['7'], + iStarted: true, + canAct: true, + origin: 'local', + handProposal: TERMS, + }, + }); const runtime = new SessionMachineRuntime( createSessionMachineState( createSessionModel({ @@ -360,17 +371,7 @@ describe('session machine causal sequences', () => { terminal: INITIAL_GAME_TERMINAL_MODEL, }, }, - handState: { - gameType: 'calpoker', - version: 1n, - state: { - playerHand: [], - opponentHand: [], - cardSelections: [], - moveNumber: 0n, - isPlayerTurn: true, - }, - }, + handState, }, betweenHand: { lastHandProposal: TERMS }, }), diff --git a/games/calpoker/ui/handProposal.ts b/games/calpoker/ui/handProposal.ts index 300ddb16f..e99521a85 100644 --- a/games/calpoker/ui/handProposal.ts +++ b/games/calpoker/ui/handProposal.ts @@ -94,8 +94,13 @@ const registration: GameFeatureRegistration< senderGoesFirst: this.lifecycle.proposalSenderGoesFirst(iStarted), }; }, - decodeHandProposal(base, params) { - if (params.perPlayerStake !== base.myContribution) return null; + 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; }, diff --git a/games/host/index.ts b/games/host/index.ts index 9553ba646..f1f228fc4 100644 --- a/games/host/index.ts +++ b/games/host/index.ts @@ -1,4 +1,4 @@ -import { type ComponentType, type ReactElement } from 'react'; +import { createElement, type ComponentType, type ReactElement } from 'react'; import { Program } from 'clvm-lib'; /** Compact settlement outcome ids (snake_case; match Rust `SettlementOutcome`). */ @@ -171,7 +171,15 @@ export interface FactoryParameterCodec { export function readClvmProgram(value: unknown): Program | null { if (!(value instanceof Uint8Array)) return null; try { - return Program.deserialize(value); + 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; } @@ -194,8 +202,12 @@ export function readClvmFlag(program: Program): boolean | null { export function readClvmList(program: Program, length: number): readonly Program[] | null { if (!program.isCons) return null; - const items = program.toList(); - return items.length === length ? items : null; + try { + const items = program.toList(true); + return items.length === length ? items : null; + } catch { + return null; + } } export function defineGameStateCodec(definition: { @@ -266,6 +278,12 @@ export interface HandProposalFormProps { 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; } @@ -398,7 +416,11 @@ export interface GameFeatureRegistration< toHandProposal(draft: TDraft, gameTimeout: bigint): HandProposal | null; }; toFactoryParameters(handProposal: HandProposal, iStarted: boolean): TParams; - decodeHandProposal(base: HandProposalBase, params: TParams): HandProposal | null; + decodeHandProposal( + base: HandProposalBase, + params: TParams, + context: HandProposalDecodeContext, + ): HandProposal | null; validateHandProposal(handProposal: HandProposal): boolean; handProposalsEqual(a: HandProposal, b: HandProposal): boolean; persistence: { @@ -418,14 +440,119 @@ export interface GameFeatureRegistration< }; } -export interface GamePackage< - TState = unknown, - TDraft = ComposeDraftValue, - TFeatureState = TState, - TParams = unknown, -> - extends GameFeatureRegistration, GameMountRegistration { - HandProposalForm: ComponentType>; +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 { diff --git a/games/spacepoker/ui/handProposal.ts b/games/spacepoker/ui/handProposal.ts index e56630f3d..085fcf409 100644 --- a/games/spacepoker/ui/handProposal.ts +++ b/games/spacepoker/ui/handProposal.ts @@ -89,8 +89,13 @@ const registration: GameFeatureRegistration< senderGoesFirst: this.lifecycle.proposalSenderGoesFirst(iStarted), }; }, - decodeHandProposal(base, params) { - if (params.perPlayerStake !== base.myContribution) return null; + decodeHandProposal(base, params, context) { + if ( + params.perPlayerStake !== base.myContribution || + params.senderGoesFirst !== context.expectedSenderGoesFirst + ) { + return null; + } const handProposal = { gameType: 'spacepoker', ...base,