@seriousgaming/sdk-tools 0.0.0-stage → 1.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +93 -0
- package/README.md +38 -2
- package/dist/bundling/logic-exports.d.ts +34 -0
- package/dist/bundling/node-builtins.d.ts +21 -0
- package/dist/bundling/transpiler.d.ts +7 -0
- package/dist/cli.js +27404 -0
- package/dist/client/dev-server.d.ts +59 -0
- package/dist/client/index.d.ts +28 -0
- package/dist/client/stub-services.d.ts +32 -0
- package/dist/client/test-session.d.ts +195 -0
- package/dist/client.js +16920 -0
- package/dist/core/asset-reference-translator.d.ts +46 -0
- package/dist/core/clock.d.ts +57 -0
- package/dist/core/faults.d.ts +65 -0
- package/dist/core/game-settings.d.ts +65 -0
- package/dist/core/history.d.ts +89 -0
- package/dist/core/listen-with-fallback.d.ts +30 -0
- package/dist/core/mutation-queue.d.ts +61 -0
- package/dist/core/package-scenario.d.ts +89 -0
- package/dist/core/participant-registry.d.ts +109 -0
- package/dist/core/pending-changes.d.ts +14 -0
- package/dist/core/protocol.d.ts +83 -0
- package/dist/core/scenario-drafts.d.ts +32 -0
- package/dist/core/session-endpoints.d.ts +108 -0
- package/dist/core/session-gates.d.ts +75 -0
- package/dist/core/session-host.d.ts +94 -0
- package/dist/core/session-refusals.d.ts +56 -0
- package/dist/core/session-rewind.d.ts +32 -0
- package/dist/core/session-roster.d.ts +128 -0
- package/dist/core/session-surfaces.d.ts +32 -0
- package/dist/core/session-ticks.d.ts +18 -0
- package/dist/core/session-timers.d.ts +84 -0
- package/dist/core/session-writes.d.ts +40 -0
- package/dist/core/session.d.ts +385 -0
- package/dist/core/settings-locks.d.ts +22 -0
- package/dist/core/team-names.d.ts +52 -0
- package/dist/core/team-state-store.d.ts +218 -0
- package/dist/core/tick-timer.d.ts +75 -0
- package/dist/dev/blob-links.d.ts +10 -0
- package/dist/dev/console/assets/index-DgFglsnA.js +204 -0
- package/dist/dev/console/index.html +12 -0
- package/dist/dev/console-path.d.ts +19 -0
- package/dist/dev/lockfile.d.ts +124 -0
- package/dist/dev/package-watcher.d.ts +21 -0
- package/dist/dev/routes.d.ts +67 -0
- package/dist/dev/shell.d.ts +260 -0
- package/dist/dev/surfaces.d.ts +41 -0
- package/dist/dev/tools-identity.d.ts +23 -0
- package/dist/dev/view/batch.d.ts +172 -0
- package/dist/dev/view/commands.d.ts +75 -0
- package/dist/dev/view/route.d.ts +58 -0
- package/dist/dev/view/view.d.ts +78 -0
- package/dist/dev/view/viewer.d.ts +32 -0
- package/dist/discovery/config.d.ts +9 -0
- package/dist/discovery/manifest-paths.d.ts +21 -0
- package/dist/discovery/removed-fields.d.ts +90 -0
- package/dist/docs/api/deck.md +386 -0
- package/dist/docs/api/index.md +67 -0
- package/dist/docs/api/sdk-commands.md +286 -0
- package/dist/docs/api/sdk-e2e.md +433 -0
- package/dist/docs/api/sdk-projection.md +207 -0
- package/dist/docs/api/sdk-testing.md +214 -0
- package/dist/docs/api/sdk-tools-client.md +560 -0
- package/dist/docs/api/sdk-ui-testing.md +231 -0
- package/dist/docs/api/sdk-ui.md +1269 -0
- package/dist/docs/api/sdk.md +2140 -0
- package/dist/docs/changelog-0.x.md +1156 -0
- package/dist/docs/changelog.md +241 -0
- package/dist/docs/cli-and-dev-server.md +217 -0
- package/dist/docs/cli-error-codes.md +156 -0
- package/dist/docs/cli-reference.md +2489 -0
- package/dist/docs/cli-simulation-testing.md +222 -0
- package/dist/docs/concepts/index.md +46 -0
- package/dist/docs/concepts/terminology.md +322 -0
- package/dist/docs/environment-variables.md +102 -0
- package/dist/docs/getting-started.md +145 -0
- package/dist/docs/guides/ai-facilitation.md +220 -0
- package/dist/docs/guides/capabilities.md +460 -0
- package/dist/docs/guides/endpoints.md +335 -0
- package/dist/docs/guides/engine-testing.md +477 -0
- package/dist/docs/guides/engine.md +527 -0
- package/dist/docs/guides/file-handling.md +405 -0
- package/dist/docs/guides/games.md +176 -0
- package/dist/docs/guides/global-settings.md +94 -0
- package/dist/docs/guides/guide.md +123 -0
- package/dist/docs/guides/hooks.md +183 -0
- package/dist/docs/guides/index.md +51 -0
- package/dist/docs/guides/internationalization.md +288 -0
- package/dist/docs/guides/llm-integration.md +635 -0
- package/dist/docs/guides/multi-team-games.md +586 -0
- package/dist/docs/guides/patterns.md +391 -0
- package/dist/docs/guides/presentation.md +196 -0
- package/dist/docs/guides/production-constraints.md +130 -0
- package/dist/docs/guides/scenario-editor.md +143 -0
- package/dist/docs/guides/scenarios.md +515 -0
- package/dist/docs/guides/session.md +972 -0
- package/dist/docs/guides/settings.md +458 -0
- package/dist/docs/guides/setup.md +193 -0
- package/dist/docs/guides/speech-generation.md +233 -0
- package/dist/docs/guides/starter-game.md +164 -0
- package/dist/docs/guides/testing.md +577 -0
- package/dist/docs/guides/tick-controller.md +569 -0
- package/dist/docs/guides/type-safety.md +516 -0
- package/dist/docs/guides/ui.md +1389 -0
- package/dist/docs/guides/upgrading.md +636 -0
- package/dist/docs/guides/validators.md +217 -0
- package/dist/docs/guides/waiting-room.md +244 -0
- package/dist/docs/index.md +41 -0
- package/dist/docs/recipes/act-on-the-session-from-an-endpoint.md +95 -0
- package/dist/docs/recipes/adapt-a-game-for-walkthroughs.md +133 -0
- package/dist/docs/recipes/adapt-ui-for-observers.md +74 -0
- package/dist/docs/recipes/call-endpoint-from-ui.md +109 -0
- package/dist/docs/recipes/check-a-package-from-a-script.md +117 -0
- package/dist/docs/recipes/create-capability.md +66 -0
- package/dist/docs/recipes/create-endpoint.md +127 -0
- package/dist/docs/recipes/create-engine.md +111 -0
- package/dist/docs/recipes/create-game.md +70 -0
- package/dist/docs/recipes/create-guide.md +75 -0
- package/dist/docs/recipes/create-hook.md +80 -0
- package/dist/docs/recipes/create-presentation.md +142 -0
- package/dist/docs/recipes/create-scenario-editor.md +102 -0
- package/dist/docs/recipes/create-scenario.md +98 -0
- package/dist/docs/recipes/create-session-ui.md +116 -0
- package/dist/docs/recipes/create-settings.md +88 -0
- package/dist/docs/recipes/create-setup.md +115 -0
- package/dist/docs/recipes/create-tick-controller.md +65 -0
- package/dist/docs/recipes/create-user-interface.md +96 -0
- package/dist/docs/recipes/create-validator.md +67 -0
- package/dist/docs/recipes/create-waiting-room-ui.md +109 -0
- package/dist/docs/recipes/declare-api-uses.md +67 -0
- package/dist/docs/recipes/design-state-for-concurrent-writes.md +96 -0
- package/dist/docs/recipes/distribute-package.md +80 -0
- package/dist/docs/recipes/e2e-testing.md +116 -0
- package/dist/docs/recipes/end-game-on-condition.md +154 -0
- package/dist/docs/recipes/fix-duplicate-packages.md +101 -0
- package/dist/docs/recipes/index.md +86 -0
- package/dist/docs/recipes/make-an-endpoint-external.md +101 -0
- package/dist/docs/recipes/negotiate-across-teams.md +164 -0
- package/dist/docs/recipes/observe-other-teams-state.md +119 -0
- package/dist/docs/recipes/publish-a-package.md +139 -0
- package/dist/docs/recipes/remember-a-viewers-choice.md +55 -0
- package/dist/docs/recipes/review-a-past-day.md +73 -0
- package/dist/docs/recipes/share-assets-across-scenarios.md +120 -0
- package/dist/docs/recipes/share-logic-between-engine-and-ui.md +130 -0
- package/dist/docs/recipes/show-all-teams-participants.md +109 -0
- package/dist/docs/recipes/start-from-an-empty-package.md +169 -0
- package/dist/docs/recipes/take-a-file-in-an-endpoint.md +82 -0
- package/dist/docs/recipes/test-a-multi-role-game.md +51 -0
- package/dist/docs/recipes/test-user-interface.md +95 -0
- package/dist/docs/recipes/test-with-engine.md +115 -0
- package/dist/docs/recipes/upload-and-display-files.md +148 -0
- package/dist/docs/recipes/use-request-play.md +65 -0
- package/dist/docs/recipes/use-request-tick.md +75 -0
- package/dist/docs/troubleshooting.md +816 -0
- package/dist/execution/blob-inputs.d.ts +57 -0
- package/dist/execution/bundle-reload.d.ts +42 -0
- package/dist/execution/config-resolver.d.ts +127 -0
- package/dist/execution/configuration-builder.d.ts +66 -0
- package/dist/execution/context-composition.d.ts +161 -0
- package/dist/execution/endpoint-actions.d.ts +9 -0
- package/dist/execution/endpoint-payloads.d.ts +32 -0
- package/dist/execution/endpoint-runner.d.ts +129 -0
- package/dist/execution/engine-state.d.ts +40 -0
- package/dist/execution/executor-callbacks.d.ts +56 -0
- package/dist/execution/executor.d.ts +436 -0
- package/dist/execution/gates.d.ts +45 -0
- package/dist/execution/hook-chain.d.ts +25 -0
- package/dist/execution/logic-calls.d.ts +190 -0
- package/dist/execution/logic-failure.d.ts +84 -0
- package/dist/execution/logic-loader.d.ts +54 -0
- package/dist/execution/logic-result.d.ts +76 -0
- package/dist/execution/tick-controller-call.d.ts +32 -0
- package/dist/execution/types.d.ts +48 -0
- package/dist/generation/ts-type.d.ts +39 -0
- package/dist/guide/locale.d.ts +12 -0
- package/dist/guide/video-registry.d.ts +42 -0
- package/dist/help-examples.d.ts +125 -0
- package/dist/host/adapter.d.ts +67 -0
- package/dist/host/host-channel.d.ts +222 -0
- package/dist/host/index.d.ts +20 -0
- package/dist/host/protocol.d.ts +783 -0
- package/dist/host/surfaces.d.ts +42 -0
- package/dist/host/theme.d.ts +55 -0
- package/dist/host.js +583 -0
- package/dist/prompts/index.d.ts +7 -0
- package/dist/prompts/schema-builder.d.ts +29 -0
- package/dist/prompts/types.d.ts +108 -0
- package/dist/prompts/validators.d.ts +32 -0
- package/dist/runtime/capability-scope.d.ts +6 -0
- package/dist/runtime/context-merge.d.ts +29 -0
- package/dist/runtime/context-projection.d.ts +41 -0
- package/dist/runtime/guarded-callbacks.d.ts +15 -0
- package/dist/runtime/index.d.ts +27 -0
- package/dist/runtime/manifest-version.d.ts +55 -0
- package/dist/runtime/tick-validation.d.ts +25 -0
- package/dist/runtime.js +346 -0
- package/dist/schema.json +1546 -0
- package/dist/server/vite-plugin.d.ts +46 -0
- package/dist/services/tts/client.d.ts +51 -0
- package/dist/services/tts/elevenlabs.d.ts +24 -0
- package/dist/services/tts/types.d.ts +44 -0
- package/dist/storage/asset-resolver.d.ts +49 -0
- package/dist/storage/blob-store.d.ts +41 -0
- package/dist/templates/endpoint/logic.test.ts.liquid +61 -0
- package/dist/templates/endpoint/logic.ts.liquid +25 -0
- package/dist/templates/engine/init.json.liquid +7 -0
- package/dist/templates/engine/logic-aggregate.test.ts.liquid +90 -0
- package/dist/templates/engine/logic-aggregate.ts.liquid +46 -0
- package/dist/templates/engine/logic.test.ts.liquid +61 -0
- package/dist/templates/engine/logic.ts.liquid +32 -0
- package/dist/templates/engine/run.json.liquid +7 -0
- package/dist/templates/guide/01-at-a-glance.md.liquid +19 -0
- package/dist/templates/guide/02-preparation.md.liquid +18 -0
- package/dist/templates/guide/03-running-the-session.md.liquid +37 -0
- package/dist/templates/hook/logic.test.ts.liquid +46 -0
- package/dist/templates/hook/logic.ts.liquid +12 -0
- package/dist/templates/package/CLAUDE.md.template +338 -0
- package/dist/templates/package/DESIGN.md.liquid +144 -0
- package/dist/templates/package/README.md.liquid +143 -0
- package/dist/templates/package/claude/rules/workflow-create-capability.md +26 -0
- package/dist/templates/package/claude/rules/workflow-create-endpoint.md +35 -0
- package/dist/templates/package/claude/rules/workflow-create-engine.md +35 -0
- package/dist/templates/package/claude/rules/workflow-create-guide.md +40 -0
- package/dist/templates/package/claude/rules/workflow-create-presentation.md +37 -0
- package/dist/templates/package/claude/rules/workflow-create-user-interface.md +31 -0
- package/dist/templates/package/claude/rules/workflow-publish-package.md +54 -0
- package/dist/templates/package/claude/rules/workflow-test-engine-or-ui.md +30 -0
- package/dist/templates/package/claude/rules/workflow-update-sdk-version.md +43 -0
- package/dist/templates/package/claude/settings.json.template +16 -0
- package/dist/templates/package/gitignore.template +15 -0
- package/dist/templates/package/global-setup.ts.template +21 -0
- package/dist/templates/package/globals.d.ts.template +2 -0
- package/dist/templates/package/oxfmtrc.json.template +14 -0
- package/dist/templates/package/oxlintrc.json.template +238 -0
- package/dist/templates/package/package.json.liquid +65 -0
- package/dist/templates/package/playwright.config.ts.liquid +19 -0
- package/dist/templates/package/pnpm-workspace.yaml.template +27 -0
- package/dist/templates/package/test-setup.ts.liquid +7 -0
- package/dist/templates/package/tsconfig.json.liquid +15 -0
- package/dist/templates/package/vite.config.ts.liquid +19 -0
- package/dist/templates/package/vitest.config.ts.liquid +25 -0
- package/dist/templates/package/vitest.engine.config.ts.liquid +12 -0
- package/dist/templates/presentation/index.html.liquid +20 -0
- package/dist/templates/presentation/src/App.tsx.liquid +49 -0
- package/dist/templates/presentation/src/Slides.tsx.liquid +81 -0
- package/dist/templates/presentation/src/i18n.ts.liquid +54 -0
- package/dist/templates/presentation/src/main.tsx.liquid +14 -0
- package/dist/templates/scenario_editor/src/App.test.tsx.liquid +30 -0
- package/dist/templates/scenario_editor/src/App.tsx.liquid +95 -0
- package/dist/templates/scenario_editor/src/i18n.ts.liquid +44 -0
- package/dist/templates/session/src/App.test.tsx.liquid +60 -0
- package/dist/templates/session/src/App.tsx.liquid +128 -0
- package/dist/templates/session/src/i18n.ts.liquid +53 -0
- package/dist/templates/session/src/main.tsx.liquid +48 -0
- package/dist/templates/session/src/theme.ts.liquid +46 -0
- package/dist/templates/settings-editor/index.html.liquid +12 -0
- package/dist/templates/settings-editor/src/main.tsx.liquid +48 -0
- package/dist/templates/settings-editor/src/theme.ts.liquid +24 -0
- package/dist/templates/setup/src/App.test.tsx.liquid +39 -0
- package/dist/templates/setup/src/App.tsx.liquid +102 -0
- package/dist/templates/setup/src/i18n.ts.liquid +47 -0
- package/dist/templates/starter-game/capabilities/briefing/capability.sdk.json +18 -0
- package/dist/templates/starter-game/capabilities/plan/capability.sdk.json +17 -0
- package/dist/templates/starter-game/capabilities/results/capability.sdk.json +61 -0
- package/dist/templates/starter-game/capabilities/roles/capability.sdk.json +17 -0
- package/dist/templates/starter-game/capabilities/weather/capability.sdk.json +23 -0
- package/dist/templates/starter-game/engine/defaults.ts +81 -0
- package/dist/templates/starter-game/engine/engine.sdk.json +12 -0
- package/dist/templates/starter-game/engine/logic.test.ts +221 -0
- package/dist/templates/starter-game/engine/logic.ts +149 -0
- package/dist/templates/starter-game/engine/test-helpers.ts +74 -0
- package/dist/templates/starter-game/engine/types.ts +139 -0
- package/dist/templates/starter-game/guide/content/en/01-at-a-glance.md +51 -0
- package/dist/templates/starter-game/guide/content/en/02-preparation.md +35 -0
- package/dist/templates/starter-game/guide/content/en/03-running-the-day.md +58 -0
- package/dist/templates/starter-game/guide/guide.sdk.json +10 -0
- package/dist/templates/starter-game/guide/guide.test.ts +108 -0
- package/dist/templates/starter-game/hooks/assignproducts/defaults.ts +37 -0
- package/dist/templates/starter-game/hooks/assignproducts/hook.sdk.json +13 -0
- package/dist/templates/starter-game/hooks/assignproducts/logic/logic.test.ts +77 -0
- package/dist/templates/starter-game/hooks/assignproducts/logic/logic.ts +47 -0
- package/dist/templates/starter-game/hooks/assignproducts/types.ts +55 -0
- package/dist/templates/starter-game/package.json +68 -0
- package/dist/templates/starter-game/playwright.config.ts +19 -0
- package/dist/templates/starter-game/scenarios/heatwave/scenario.sdk.json +20 -0
- package/dist/templates/starter-game/scenarios/ordinary_week/scenario.sdk.json +12 -0
- package/dist/templates/starter-game/seriousgaming.sdk.json +23 -0
- package/dist/templates/starter-game/session/defaults.ts +80 -0
- package/dist/templates/starter-game/session/index.html +12 -0
- package/dist/templates/starter-game/session/session.sdk.json +11 -0
- package/dist/templates/starter-game/session/src/App.test.tsx +307 -0
- package/dist/templates/starter-game/session/src/App.tsx +309 -0
- package/dist/templates/starter-game/session/src/Section.tsx +37 -0
- package/dist/templates/starter-game/session/src/TeamDoor.tsx +96 -0
- package/dist/templates/starter-game/session/src/format.ts +8 -0
- package/dist/templates/starter-game/session/src/i18n.ts +51 -0
- package/dist/templates/starter-game/session/src/main.tsx +48 -0
- package/dist/templates/starter-game/session/src/theme.ts +45 -0
- package/dist/templates/starter-game/session/types.ts +135 -0
- package/dist/templates/starter-game/settings/engine/settings.sdk.json +50 -0
- package/dist/templates/starter-game/settings/menu/settings.sdk.json +78 -0
- package/dist/templates/starter-game/settings/session/settings.sdk.json +23 -0
- package/dist/templates/starter-game/settings/ui/settings.sdk.json +25 -0
- package/dist/templates/starter-game/test-setup.ts +7 -0
- package/dist/templates/starter-game/testing/e2e/game.spec.ts +139 -0
- package/dist/templates/starter-game/testing/e2e/global-setup.ts +21 -0
- package/dist/templates/starter-game/testing/engine/game.engine.test.ts +171 -0
- package/dist/templates/starter-game/tsconfig.json +19 -0
- package/dist/templates/starter-game/ui/defaults.ts +60 -0
- package/dist/templates/starter-game/ui/index.html +12 -0
- package/dist/templates/starter-game/ui/src/App.test.tsx +138 -0
- package/dist/templates/starter-game/ui/src/App.tsx +175 -0
- package/dist/templates/starter-game/ui/src/DayBar.test.tsx +78 -0
- package/dist/templates/starter-game/ui/src/DayBar.tsx +72 -0
- package/dist/templates/starter-game/ui/src/HistoryPanel.test.tsx +74 -0
- package/dist/templates/starter-game/ui/src/HistoryPanel.tsx +120 -0
- package/dist/templates/starter-game/ui/src/MakeRow.tsx +129 -0
- package/dist/templates/starter-game/ui/src/Section.tsx +37 -0
- package/dist/templates/starter-game/ui/src/ThemeToggle.tsx +49 -0
- package/dist/templates/starter-game/ui/src/TodayPanel.test.tsx +179 -0
- package/dist/templates/starter-game/ui/src/TodayPanel.tsx +92 -0
- package/dist/templates/starter-game/ui/src/YesterdayPanel.tsx +142 -0
- package/dist/templates/starter-game/ui/src/format.ts +12 -0
- package/dist/templates/starter-game/ui/src/i18n.ts +95 -0
- package/dist/templates/starter-game/ui/src/main.tsx +48 -0
- package/dist/templates/starter-game/ui/src/menu.ts +18 -0
- package/dist/templates/starter-game/ui/src/test-helpers.ts +58 -0
- package/dist/templates/starter-game/ui/src/theme.ts +45 -0
- package/dist/templates/starter-game/ui/src/writes.ts +43 -0
- package/dist/templates/starter-game/ui/types.ts +137 -0
- package/dist/templates/starter-game/ui/ui.sdk.json +11 -0
- package/dist/templates/starter-game/validators/capacity/defaults.ts +37 -0
- package/dist/templates/starter-game/validators/capacity/logic/logic.test.ts +96 -0
- package/dist/templates/starter-game/validators/capacity/logic/logic.ts +45 -0
- package/dist/templates/starter-game/validators/capacity/types.ts +55 -0
- package/dist/templates/starter-game/validators/capacity/validator.sdk.json +11 -0
- package/dist/templates/starter-game/vite.config.ts +19 -0
- package/dist/templates/starter-game/vitest.config.ts +15 -0
- package/dist/templates/starter-game/vitest.engine.config.ts +19 -0
- package/dist/templates/starter-game/waiting_room/defaults.ts +40 -0
- package/dist/templates/starter-game/waiting_room/index.html +12 -0
- package/dist/templates/starter-game/waiting_room/src/App.test.tsx +102 -0
- package/dist/templates/starter-game/waiting_room/src/App.tsx +194 -0
- package/dist/templates/starter-game/waiting_room/src/i18n.ts +33 -0
- package/dist/templates/starter-game/waiting_room/src/main.tsx +48 -0
- package/dist/templates/starter-game/waiting_room/src/theme.ts +45 -0
- package/dist/templates/starter-game/waiting_room/types.ts +59 -0
- package/dist/templates/starter-game/waiting_room/waiting_room.sdk.json +10 -0
- package/dist/templates/tick-controller/logic.ts.liquid +8 -0
- package/dist/templates/ui/index.html.liquid +12 -0
- package/dist/templates/ui/src/App.test.tsx.liquid +41 -0
- package/dist/templates/ui/src/App.tsx.liquid +103 -0
- package/dist/templates/ui/src/i18n.ts.liquid +47 -0
- package/dist/templates/ui/src/main.tsx.liquid +48 -0
- package/dist/templates/ui/src/theme.ts.liquid +46 -0
- package/dist/templates/validator/logic.test.ts.liquid +42 -0
- package/dist/templates/validator/logic.ts.liquid +21 -0
- package/dist/templates/waiting-room/index.html.liquid +12 -0
- package/dist/templates/waiting-room/src/App.test.tsx.liquid +57 -0
- package/dist/templates/waiting-room/src/App.tsx.liquid +118 -0
- package/dist/templates/waiting-room/src/i18n.ts.liquid +44 -0
- package/dist/templates/waiting-room/src/main.tsx.liquid +48 -0
- package/dist/templates/waiting-room/src/theme.ts.liquid +46 -0
- package/dist/templating/resource-metadata.d.ts +131 -0
- package/dist/types.d.ts +14 -0
- package/dist/utils/atomic-write.d.ts +40 -0
- package/dist/utils/cli-error.d.ts +64 -0
- package/dist/utils/document-parser-client.d.ts +20 -0
- package/dist/utils/env-loader.d.ts +40 -0
- package/dist/utils/error-codes.d.ts +339 -0
- package/dist/utils/errors.d.ts +19 -0
- package/dist/utils/global-settings.d.ts +65 -0
- package/dist/utils/llm-client.d.ts +34 -0
- package/dist/utils/logger.d.ts +36 -0
- package/dist/utils/output.d.ts +109 -0
- package/dist/utils/platform.d.ts +14 -0
- package/dist/utils/prompt.d.ts +36 -0
- package/dist/utils/real-path.d.ts +6 -0
- package/dist/utils/schema-properties.d.ts +35 -0
- package/dist/validation/ajv.d.ts +17 -0
- package/dist/validation/facilitator-guide-validator.d.ts +27 -0
- package/dist/validation/findings.d.ts +40 -0
- package/dist/validation/game-constraints.d.ts +10 -0
- package/dist/validation/game-validator.d.ts +34 -0
- package/dist/validation/package-validation.d.ts +41 -0
- package/dist/validation/package-validator.d.ts +19 -0
- package/dist/validation/resource-validator.d.ts +79 -0
- package/dist/validation/schema-defaults-validator.d.ts +29 -0
- package/dist/validation/schema-validation.d.ts +115 -0
- package/dist/validation/semver.d.ts +9 -0
- package/package.json +123 -3
|
@@ -0,0 +1,1389 @@
|
|
|
1
|
+
# Building user interfaces
|
|
2
|
+
|
|
3
|
+
This guide shows you how to create and develop custom user interfaces for your games.
|
|
4
|
+
|
|
5
|
+
## What you'll learn
|
|
6
|
+
|
|
7
|
+
- How to create a new user interface
|
|
8
|
+
- How to read game context and settings
|
|
9
|
+
- How to write to context and request a tick
|
|
10
|
+
- How to work with participants, teams, observers, and walkthroughs
|
|
11
|
+
- How to show the tick timer, localise, theme, and upload files
|
|
12
|
+
- How to reach an LLM from a UI, through an endpoint
|
|
13
|
+
|
|
14
|
+
## Creating a user interface
|
|
15
|
+
|
|
16
|
+
The starter game already fills this slot, and `new` refuses a filled one with `RESOURCE_EXISTS`. In a package made by `new package`, change it with `update ui` instead, or start from `new package --empty`.
|
|
17
|
+
|
|
18
|
+
Create a new user interface:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pnpm run sdk new ui '{
|
|
22
|
+
"definition": {
|
|
23
|
+
"reads": ["inventory", "sales", "pricing"],
|
|
24
|
+
"writes": [],
|
|
25
|
+
"settings": ["ui"]
|
|
26
|
+
},
|
|
27
|
+
"ui": {
|
|
28
|
+
"entrypoint": "index.html"
|
|
29
|
+
},
|
|
30
|
+
"rootDirectory": "."
|
|
31
|
+
}'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`settings` names the settings resources the UI reads, here a `settings/ui/` declaring `RefreshRate` (create it first with `pnpm run sdk new settings`; see [Create settings](../recipes/create-settings.md)). Leave it out and the UI reads none.
|
|
35
|
+
|
|
36
|
+
This creates a UI structure:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
ui/
|
|
40
|
+
├── ui.sdk.json
|
|
41
|
+
├── index.html
|
|
42
|
+
├── src/
|
|
43
|
+
│ ├── main.tsx
|
|
44
|
+
│ ├── App.tsx
|
|
45
|
+
│ ├── App.test.tsx
|
|
46
|
+
│ ├── i18n.ts
|
|
47
|
+
│ └── theme.ts
|
|
48
|
+
└── types.ts
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Accessing context and settings
|
|
52
|
+
|
|
53
|
+
Access game context and settings in your React components:
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
// src/App.tsx
|
|
57
|
+
import { useUserInterfaceData } from '@seriousgaming/sdk/ui';
|
|
58
|
+
import type { Context, Settings } from '../types';
|
|
59
|
+
|
|
60
|
+
function Dashboard() {
|
|
61
|
+
const { data, settings } = useUserInterfaceData<Context, Settings>();
|
|
62
|
+
|
|
63
|
+
const { Stock } = data.inventory;
|
|
64
|
+
const { Revenue } = data.sales;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div>
|
|
68
|
+
<h1>Dashboard</h1>
|
|
69
|
+
<p>Current Stock: {Stock} units</p>
|
|
70
|
+
<p>Revenue: ${Revenue}</p>
|
|
71
|
+
<p>Refresh rate: {settings.ui.RefreshRate}ms</p>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export default Dashboard;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The `useUserInterfaceData` hook provides:
|
|
80
|
+
|
|
81
|
+
- **`data`**: The game context (read-only by default)
|
|
82
|
+
- **`settings`**: The settings resources this UI lists in `settings`, keyed by identifier (`settings.ui.RefreshRate`). Read-only, and fixed when play starts
|
|
83
|
+
- **`completedTicks`**: The number of ticks this team has completed: `0` until the first tick commits, `N - 1` while round N is played. Use it to bound a review scrubber (see below)
|
|
84
|
+
- **`isRoundProcessing`**: Whether the host is running a tick right now (see below)
|
|
85
|
+
- **`setData`**: Function to update context (available only for capabilities in `writes`)
|
|
86
|
+
|
|
87
|
+
### Saying a tick is processing
|
|
88
|
+
|
|
89
|
+
In a shared-state game every team ticks together, so the host settles all of them in one go. In a large session that takes long enough for participants to see, and they did not press the button, so an unexplained pause reads as the screen having broken. `isRoundProcessing` lets you say what is happening:
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
const { isRoundProcessing } = useUserInterfaceData<Context, Settings>();
|
|
93
|
+
|
|
94
|
+
if (isRoundProcessing) {
|
|
95
|
+
return <p>Processing round…</p>;
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Show it in place of a countdown, which has nothing to count down to mid-tick.
|
|
100
|
+
|
|
101
|
+
**Your writes still work.** This is a signal, not a closed window. A `setData` during a tick is queued by the host and applied after the tick, and its promise resolves when it commits, exactly as at any other time. So:
|
|
102
|
+
|
|
103
|
+
- Disable a submit button on the flag if you would rather participants waited for the new numbers
|
|
104
|
+
- Or leave it enabled, and nothing is lost
|
|
105
|
+
|
|
106
|
+
Both are fine. Nothing about the flag changes whether a write is accepted.
|
|
107
|
+
|
|
108
|
+
A session UI does not receive it, and does not need it: `requestTick` returns a promise, so a facilitator screen can render 'processing' between calling it and its resolution.
|
|
109
|
+
|
|
110
|
+
### Reviewing past ticks
|
|
111
|
+
|
|
112
|
+
`useUserInterfaceCallbacks()` exposes `getPastState(query)`, a read-only reader for committed past state, composed exactly like live `data`. Entry N is the state after tick N (after `Init`, for entry 0) with the decisions the next round entered on it, so it carries tick N's outcomes and those decisions together, and a value your engine clears each tick is still reviewable. One call serves every review, because the query spans the two axes a review screen varies:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
getPastState<Context>({
|
|
116
|
+
fromTick: 3, // required, inclusive
|
|
117
|
+
toTick: 8, // optional; omit to read fromTick alone
|
|
118
|
+
teams: 'all', // 'own' (default), 'all', or a 'team-N' id
|
|
119
|
+
capabilityIds: ['profit'], // optional projection
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
It answers with one entry per (tick, team) it reaches, ascending by tick then by team. A tick that is not reviewable (`completedTicks` itself, which is the live board, or one that is undone, reset, or out of range) is simply an absent row rather than a null, so an empty array means the query reaches nothing. Tick 0 is not a played tick, so nothing is reviewable before the first tick. It rejects with a `TickSnapshotError` only on a genuine fault, and is not gated by `uses`. A session UI takes the same signature: having no own team, its `teams: 'own'` means the whole session.
|
|
124
|
+
|
|
125
|
+
Each entry extends the live cross-team shape with the tick it was captured at, so a live row from `useUserInterfaceCrossTeamData()` and a historical row read identically and one renderer serves both.
|
|
126
|
+
|
|
127
|
+
Debounce scrubbing and cache fetched entries, which are immutable within a session until an undo takes the newest tick back into live state. Note that a reset bulk-deletes reviewable history, making it a participant-visible action wherever a UI exposes review. See [Review a past day in a UI](../recipes/review-a-past-day.md) for the full pattern.
|
|
128
|
+
|
|
129
|
+
For a chart or a table over several ticks, give the query a `toTick` rather than making a call per tick: one request, and `capabilityIds` narrows each returned context to the fields you plot. Reading history back from the host is what lets you stop keeping a growing log in context, which is otherwise delivered to every screen on every update.
|
|
130
|
+
|
|
131
|
+
In a component, `usePastState(query)` does the bookkeeping for you. It reads when the query changes (compared by value, so an inline object is fine), keeps the last entries on screen while a new query loads, and drops the answer to any read a newer one has overtaken:
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
import { usePastState } from '@seriousgaming/sdk/ui';
|
|
135
|
+
|
|
136
|
+
const { completedTicks } = useUserInterfaceData<Context, Settings>();
|
|
137
|
+
const { entries, error, loading, refetch } = usePastState<Context>(
|
|
138
|
+
completedTicks > 0 ? { fromTick: 0, toTick: completedTicks, capabilityIds: ['profit'] } : null
|
|
139
|
+
);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Pass `null` to read nothing yet. `refetch()` reads the same query again. It works on the game UI, the waiting room, the session UI and a presentation, the surfaces that have `getPastState`.
|
|
143
|
+
|
|
144
|
+
### Settings are what your UI declares
|
|
145
|
+
|
|
146
|
+
`settings` holds exactly the settings resources listed in the UI's `settings`, and no others. A UI that needs one of the engine's values lists the same settings resource the engine does (`"settings": ["engine"]`) and reads `settings.engine.X`: there is no need to copy it into context at `Init`, and no copy to go stale.
|
|
147
|
+
|
|
148
|
+
The reverse is how you keep a value off participant screens. An answer key, a hidden target, or a scoring rubric goes in a settings resource that only the engine declares, and it never reaches the browser.
|
|
149
|
+
|
|
150
|
+
When you need a value in a UI, choose one:
|
|
151
|
+
|
|
152
|
+
- Read a settings resource, by listing it in `settings`. Best for static setup that is fixed for the session
|
|
153
|
+
- Write it into context from an engine and read it via `data`. Best when the value is computed or changes during play
|
|
154
|
+
|
|
155
|
+
See [Settings](./settings.md#keeping-a-value-off-participant-screens) for the full picture.
|
|
156
|
+
|
|
157
|
+
## Writing to context
|
|
158
|
+
|
|
159
|
+
Update game context from user interactions:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
import { useUserInterfaceData } from '@seriousgaming/sdk/ui';
|
|
163
|
+
import type { Context, Settings } from '../types';
|
|
164
|
+
|
|
165
|
+
function PriceController() {
|
|
166
|
+
const { data, settings, setData } = useUserInterfaceData<Context, Settings>();
|
|
167
|
+
|
|
168
|
+
const handlePriceChange = (newPrice: number) => {
|
|
169
|
+
// Update only the capability this UI provides
|
|
170
|
+
setData({
|
|
171
|
+
price: { Price: newPrice }
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<input
|
|
177
|
+
type="number"
|
|
178
|
+
value={data.price.Price}
|
|
179
|
+
onChange={(e) => handlePriceChange(parseFloat(e.target.value))}
|
|
180
|
+
min={settings.ui.MinPrice}
|
|
181
|
+
max={settings.ui.MaxPrice}
|
|
182
|
+
/>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
**Important:**
|
|
188
|
+
|
|
189
|
+
- **Write access**: Only capabilities listed in `writes` can be updated via `setData`. The runtime enforces it: a write (or a `deletes` path) naming anything else is refused as `denied`, naming the key, and nothing of it is applied. Your generated types already forbid it, so this catches an untyped or hand-built payload
|
|
190
|
+
- **Partial updates**: Pass only the fields you changed to `setData`
|
|
191
|
+
- **Scoped by identifier**: Always key the update by the capability's identifier, which is its directory name (e.g., `price`)
|
|
192
|
+
|
|
193
|
+
### How setData merges
|
|
194
|
+
|
|
195
|
+
Each `setData` call sends only the update you pass; the host deep-merges it into the current context. Objects are merged recursively, while arrays, scalars, and `null` replace the existing value wholesale. Your local view applies the same merge immediately, so a minimal patch never blanks sibling fields you did not touch.
|
|
196
|
+
|
|
197
|
+
This means you do not need to spread the whole capability into every write:
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
// Preferred: send only what changed
|
|
201
|
+
setData({ exercise: { Committed: true } });
|
|
202
|
+
|
|
203
|
+
// Unnecessary (and harmful under concurrency): re-sending untouched fields
|
|
204
|
+
setData({ exercise: { ...data.exercise, Committed: true } });
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Sending minimal patches is what lets two participants on the same team submit different decisions at the same time without overwriting each other: writes that touch different fields merge cleanly on the host. A merge has no way to express deletion, so removing an entry is a separate instruction on the same write: `setData({}, { deletes: ['pinboard.Notes.' + noteId] })`. Deletions apply after the patch, so one call can replace an entry, and a path that resolves to nothing is a no-op.
|
|
208
|
+
|
|
209
|
+
Because arrays replace wholesale, a shared array that participants read, modify, and write back is still a lost-update hazard even with minimal patches: two simultaneous appends each send an array missing the other's item. Model collaborative collections as objects keyed by a stable id instead, so concurrent writes touch disjoint keys and merge cleanly. See [Design state for concurrent writes](../recipes/design-state-for-concurrent-writes.md) for the full set of patterns, including clearing entries with `null` and arbitrating genuinely contested slots with a validator.
|
|
210
|
+
|
|
211
|
+
### How setData timing works
|
|
212
|
+
|
|
213
|
+
Understanding when `setData` changes become visible is critical for designing your game loop correctly.
|
|
214
|
+
|
|
215
|
+
1. **Immediate in context**: When a UI calls `setData`, the change is merged into the game context right away. Other UIs reading the same capability will see the updated value immediately (via WebSocket sync in the dev server).
|
|
216
|
+
|
|
217
|
+
2. **Engines process on next tick**: Engine `Run` functions only execute when a tick advances. They see whatever context exists at that point, including any `setData` changes made since the last tick.
|
|
218
|
+
|
|
219
|
+
3. **Multiple `setData` calls accumulate**: If a participant changes Price to 50, then to 75 before the next tick, the engine will see 75. Only the final state matters.
|
|
220
|
+
|
|
221
|
+
**Design implication**: Use `setData` for participant input within a tick (decisions, adjustments, form entries). Use tick advancement to trigger computation (the engine runs, state updates cascade). Don't try to run game logic inside `setData` handlers: that bypasses the tick mechanism and breaks the reactive spreadsheet model.
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
Player adjusts price via UI (setData) → context updated immediately
|
|
225
|
+
Player clicks "Submit" (tick advances) → the engine runs with the new price
|
|
226
|
+
→ UIs re-render with computed results
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Requesting a tick
|
|
230
|
+
|
|
231
|
+
User interfaces advance the game to the next tick using the `requestTick` callback. This is how games provide their 'Submit' control. There is no built-in next tick button, so every game that ticks manually needs one of these.
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
import { useUserInterfaceCallbacks } from '@seriousgaming/sdk/ui';
|
|
235
|
+
|
|
236
|
+
function DecisionPanel() {
|
|
237
|
+
const callbacks = useUserInterfaceCallbacks();
|
|
238
|
+
|
|
239
|
+
const handleSubmit = () => {
|
|
240
|
+
callbacks.requestTick?.();
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<button onClick={handleSubmit}>
|
|
245
|
+
Submit Decision
|
|
246
|
+
</button>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
During development, the dev drawer (press the backtick key) also has a developer-only **Tick** button, so you can advance the game before your control exists.
|
|
252
|
+
|
|
253
|
+
See [Host controls](./games.md#host-controls) for what the host renders and what is left to your game.
|
|
254
|
+
|
|
255
|
+
### Combining setData with requestTick
|
|
256
|
+
|
|
257
|
+
A common pattern is to update context and immediately request a tick: for example, a 'Start exercise' button that sets an initial value and advances the game:
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
const handleStart = () => {
|
|
261
|
+
setData({ exercise: { Started: true } });
|
|
262
|
+
callbacks.requestTick?.();
|
|
263
|
+
};
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
This works correctly: the `setData` change is merged into context immediately, and the tick that follows will see `Started: true` when the engine runs. However, there is a brief intermediate state that is worth understanding:
|
|
267
|
+
|
|
268
|
+
1. **`setData` merges immediately**: the local UI and all connected UIs see `Started: true` right away.
|
|
269
|
+
2. **`requestTick` is asynchronous**: the tick message is sent to the host, which queues and executes the tick. This takes a few milliseconds.
|
|
270
|
+
3. **Between steps 1 and 2**, UIs see the `setData` change but the engine has not yet run. If the engine computes derived values (e.g. a status message based on `Started`), the UI will briefly show the raw `setData` values without the engine-computed results.
|
|
271
|
+
4. **After the tick completes**, the host broadcasts updated context to all UIs. This overwrites the intermediate state with the fully computed post-tick values.
|
|
272
|
+
|
|
273
|
+
In most cases this intermediate state is invisible to the participant, because the tick completes in milliseconds. But if your UI derives display values from both `setData` fields and engine-computed fields, you may see a brief flicker. Two strategies to handle this:
|
|
274
|
+
|
|
275
|
+
**Option A: UI-level loading state**: set a local loading flag before requesting the tick, and clear it when new context arrives:
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
const [ticking, setTicking] = useState(false);
|
|
279
|
+
|
|
280
|
+
const handleStart = () => {
|
|
281
|
+
setData({ exercise: { Started: true } });
|
|
282
|
+
setTicking(true);
|
|
283
|
+
callbacks.requestTick?.();
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// Clear on next context push (setData from host resets the component)
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
setTicking(false);
|
|
289
|
+
}, [data]);
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Option B: Drive display from engine-computed values only**: if possible, have your UI read from the engine's output capability rather than from the raw `setData` input. The engine output is only updated after the tick, so there is no intermediate state.
|
|
293
|
+
|
|
294
|
+
### Awaiting state changes
|
|
295
|
+
|
|
296
|
+
`setData` and `requestTick` each return a promise, as do a session UI's `undoTick` and `resetGame` and a setup screen or scenario editor's `updateSettings`. It resolves once the operation has committed (a `setData` once the write is persisted, a `requestTick` once that tick has finished and context is updated) and rejects with a `MutationError` when it fails. The error's `outcome` is a discriminated union you can branch on:
|
|
297
|
+
|
|
298
|
+
- `conflict`: the write lost a race with a tick, and the runtime retried it itself and gave up. A person may try again; your code never retries it on its own
|
|
299
|
+
- `superseded`: what the operation addressed has already moved on because somebody else did the same thing, such as a tick button pressed twice. Terminal but not a failure: the state is what the caller asked for
|
|
300
|
+
- `validation-rejected`: a validator rejected the write; `outcome.rejections` carries the same detail delivered to `useUserInterfaceValidationRejections`
|
|
301
|
+
- `denied`: the operation was not permitted (for example, a write of a capability this UI does not declare in `writes`)
|
|
302
|
+
- `tick-failed`: the tick could not run (tick limit reached, game complete, or the executor threw)
|
|
303
|
+
- `error`: anything else unexpected
|
|
304
|
+
|
|
305
|
+
Reach for `useMutationReporter` rather than branching by hand. It awaits the write, tracks whether one is in flight, and hands you the failure to render:
|
|
306
|
+
|
|
307
|
+
```tsx
|
|
308
|
+
import { useMutationReporter } from '@seriousgaming/sdk/ui';
|
|
309
|
+
|
|
310
|
+
const { run, pending, failure, dismiss } = useMutationReporter();
|
|
311
|
+
|
|
312
|
+
<Button disabled={pending} onClick={() => run(() => setData({ exercise: { Revenue: 100 } }))}>
|
|
313
|
+
{t('save')}
|
|
314
|
+
</Button>;
|
|
315
|
+
{
|
|
316
|
+
failure && (
|
|
317
|
+
<Alert
|
|
318
|
+
onClose={dismiss}
|
|
319
|
+
action={failure.retry && <Button onClick={failure.retry}>{t('tryAgain')}</Button>}
|
|
320
|
+
>
|
|
321
|
+
{t('saveFailed')}
|
|
322
|
+
</Alert>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`setData` also resolves with which tick the write landed in. If a participant submits as the tick is being processed the write commits into the next one, and `reporter.late` says so, carrying both the tick composed against and the tick committed into. That is not a failure (the write is in), but nothing else on screen distinguishes it from a decision that arrived in time.
|
|
328
|
+
|
|
329
|
+
**The host retries; your surface never does.** A runtime using optimistic concurrency retries a losing write itself before it reports one, so a `conflict` reaching a surface is a write the host has already retried and given up on, and a surface must never retry it automatically. What a person can do is try again, so on a `conflict` (and only then) `failure.retry` is set: it runs the same operation again, resolving as `run` does. Put it behind a 'Try again' button, as the snippet above does, and never call it from an effect or a timer.
|
|
330
|
+
|
|
331
|
+
Ignoring the returned promise still compiles, but it is a hazard rather than a convenience: the local view merges the change the moment it is sent, so a refused write leaves the participant looking at a decision the game never received. A no-floating-promises lint rule may start flagging unawaited calls; route them through `run` rather than marking them with `void`.
|
|
332
|
+
|
|
333
|
+
> Note: against a hosted runtime that does not yet send the correlated acknowledgement, an awaited mutation stays pending until the connection resets. The dev server always responds, so promises settle promptly in local development.
|
|
334
|
+
|
|
335
|
+
## Accessing participant info
|
|
336
|
+
|
|
337
|
+
Use the `useUserInterfaceParticipant` hook to read the current participant's identity:
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
import { useUserInterfaceParticipant } from '@seriousgaming/sdk/ui';
|
|
341
|
+
|
|
342
|
+
function Dashboard() {
|
|
343
|
+
const participant = useUserInterfaceParticipant();
|
|
344
|
+
|
|
345
|
+
// null when the surface is being observed rather than played (see 'Observers')
|
|
346
|
+
if (!participant) return <p>Observing this team</p>;
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<div>
|
|
350
|
+
<p>Hello, {participant.name}!</p> {/* e.g. "Participant 1" */}
|
|
351
|
+
<p>Participant #{participant.index}</p> {/* sequential index */}
|
|
352
|
+
<p>Team: {participant.teamId}</p> {/* e.g. "team-1" */}
|
|
353
|
+
</div>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
| Field | Type | Description |
|
|
359
|
+
| -------- | -------- | ------------------------------------------------------ |
|
|
360
|
+
| `id` | `string` | Platform-assigned participant ID |
|
|
361
|
+
| `name` | `string` | Display name (e.g. `"Participant 1"`) |
|
|
362
|
+
| `index` | `number` | Zero-based join order within the team |
|
|
363
|
+
| `teamId` | `string` | The team this participant belongs to (e.g. `"team-1"`) |
|
|
364
|
+
|
|
365
|
+
For a participant the wrapper does not render children until the platform sends participant info, so all fields are guaranteed to be present. The one case where the hook returns `null` is an observer (see [Observers](#observers) below); guard for it. In the dev server, identity comes from a session cookie (so different browser sessions are different participants).
|
|
366
|
+
|
|
367
|
+
### Seeing the whole team
|
|
368
|
+
|
|
369
|
+
`useUserInterfaceParticipant()` returns the caller; `useUserInterfaceParticipants()` returns the full roster for the caller's team, including the caller's own entry. Each entry adds a `connected` flag to the identity fields:
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
import { useUserInterfaceParticipants } from '@seriousgaming/sdk/ui';
|
|
373
|
+
|
|
374
|
+
function Roster() {
|
|
375
|
+
const participants = useUserInterfaceParticipants();
|
|
376
|
+
|
|
377
|
+
if (participants.length === 0) return <p>Waiting for teammates…</p>;
|
|
378
|
+
|
|
379
|
+
return (
|
|
380
|
+
<ul>
|
|
381
|
+
{participants.map((p) => (
|
|
382
|
+
<li key={p.id}>
|
|
383
|
+
{p.name} {p.connected ? '🟢' : '⚪'}
|
|
384
|
+
</li>
|
|
385
|
+
))}
|
|
386
|
+
</ul>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
| Field | Type | Description |
|
|
392
|
+
| ----------- | --------- | ------------------------------------------------------ |
|
|
393
|
+
| `id` | `string` | Platform-assigned participant ID |
|
|
394
|
+
| `name` | `string` | Display name (e.g. `"Participant 1"`) |
|
|
395
|
+
| `index` | `number` | Zero-based join order within the team |
|
|
396
|
+
| `connected` | `boolean` | `true` while the participant has a live connection |
|
|
397
|
+
| `teamId` | `string` | The team this participant belongs to (e.g. `"team-1"`) |
|
|
398
|
+
|
|
399
|
+
### Participant presence lifecycle
|
|
400
|
+
|
|
401
|
+
The presence guarantees are firm, so design against them rather than defensively:
|
|
402
|
+
|
|
403
|
+
- **The caller is always present.** `useUserInterfaceParticipant()` never returns a partial value. The wrapper gates rendering on the participant message, so `id`, `name`, `index`, and `teamId` are populated before your first render
|
|
404
|
+
- **The roster may be empty on first render.** `useUserInterfaceParticipants()` is not gated, so it starts as `[]` and fills in reactively as the platform sends the list. Render an empty-roster state rather than assuming at least one entry
|
|
405
|
+
- **The caller is included in the roster.** Once populated, the list always contains the caller's own entry alongside every other participant on the team
|
|
406
|
+
- **A disconnect keeps the entry.** When a participant drops their connection their entry stays in the roster with `connected` flipped to `false`, so you can still show who was here. Participants do not leave: an entry lasts for the whole session, keeping its `id` and `index`
|
|
407
|
+
|
|
408
|
+
Because the caller is guaranteed and the roster fills in reactively, there is no separate loading flag to check: branch on `participants.length === 0` for the brief pre-population window, and on `connected` to distinguish live from dropped teammates.
|
|
409
|
+
|
|
410
|
+
**Under `sdk dev`**, a participant is connected while a page addressed to them is open, at `/team/N/player/M` with or without `?bare`. Several tabs on the same participant count once, and they stay connected until the last one closes. An observer page and the console's session surface are nobody's, so they connect nobody. To see a teammate drop out, open two participants' pages and close one.
|
|
411
|
+
|
|
412
|
+
### Seeing every team's participants
|
|
413
|
+
|
|
414
|
+
`useUserInterfaceParticipants()` covers your own team. In a shared-state game where teams deal with each other, a UI can also read the roster of every team through the `getAllTeamsParticipants()` callback, for a directory of who is on each competing team. Declare `crossTeamParticipants: true` in the UI bundle to authorise it (an undeclared UI receives an empty array), then call it and group the flat result by `teamId`:
|
|
415
|
+
|
|
416
|
+
```tsx
|
|
417
|
+
const { getAllTeamsParticipants } = useUserInterfaceCallbacks();
|
|
418
|
+
const everyone = await getAllTeamsParticipants(); // Participant[]
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Unlike the own-team hook, this is a point-in-time snapshot rather than a live subscription: call it again to refresh. See [Show all teams' participants in a UI](../recipes/show-all-teams-participants.md) for the full pattern.
|
|
422
|
+
|
|
423
|
+
### Observers
|
|
424
|
+
|
|
425
|
+
Your UI can be rendered for an **observer** as well as a participant. An observer is a facilitator watching one team's live game UI without joining as a team member, for seeing how a team is playing, troubleshooting, or running a debrief. An observer is not in the roster and has no participant identity, so `useUserInterfaceParticipant()` returns `null` in that mode.
|
|
426
|
+
|
|
427
|
+
Read the viewer role with `useUserInterfaceViewerRole()`:
|
|
428
|
+
|
|
429
|
+
```tsx
|
|
430
|
+
import { useUserInterfaceViewerRole, useUserInterfaceParticipant } from '@seriousgaming/sdk/ui';
|
|
431
|
+
|
|
432
|
+
function Decisions() {
|
|
433
|
+
const isObserver = useUserInterfaceViewerRole() === 'observer';
|
|
434
|
+
const participant = useUserInterfaceParticipant(); // null when observing
|
|
435
|
+
|
|
436
|
+
return (
|
|
437
|
+
<>
|
|
438
|
+
{isObserver && <Banner>Observing: you are watching this team, not playing</Banner>}
|
|
439
|
+
<PriceInput readOnly={isObserver} />
|
|
440
|
+
</>
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Most UIs need no changes: an observer sees the live game UI exactly as a participant does, and can still drive it. Any change an observer makes goes to the game's validators, as a participant's would, with `actor.role` set to `'observer'` so a validator can treat it differently (see [Validators](./validators.md)). Reach for `useUserInterfaceViewerRole()` only when you want to adapt: hide a participant-only control (anything keyed by participant id has no id to write under), reveal state you keep hidden during play, or present a deliberately read-only debrief view.
|
|
446
|
+
|
|
447
|
+
One read does need attention. To find out **which team** your UI is rendering, use `useUserInterfaceOwnTeam()`, not `useUserInterfaceParticipant()?.teamId`:
|
|
448
|
+
|
|
449
|
+
```typescript
|
|
450
|
+
import { useUserInterfaceOwnTeam } from '@seriousgaming/sdk/ui';
|
|
451
|
+
|
|
452
|
+
const ownTeam = useUserInterfaceOwnTeam(); // { teamId, index, name }, populated when observing too
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
The participant route is the obvious one and it is `null` under observation, so anything derived from it (marking your row in a cross-team list, labelling a board, keying a write) works when played and breaks when observed. `useUserInterfaceOwnTeam()` comes from the host, which always knows which surface it mounted. Do not confuse it with the plural `useUserInterfaceTeams()`, which lists every team in the session without marking yours.
|
|
456
|
+
|
|
457
|
+
### Walkthroughs
|
|
458
|
+
|
|
459
|
+
Your UI can also be rendered in a **walkthrough**: a disposable second run of the game beside the session, for demonstrating it. One team, one participant, always from tick 0, touching nothing real. It is what a facilitator puts on a projector while talking a room through the game.
|
|
460
|
+
|
|
461
|
+
Read it with `useRunKind()`:
|
|
462
|
+
|
|
463
|
+
```tsx
|
|
464
|
+
import { useRunKind } from '@seriousgaming/sdk/ui';
|
|
465
|
+
|
|
466
|
+
function Leaderboard() {
|
|
467
|
+
const runKind = useRunKind();
|
|
468
|
+
const data = useUserInterfaceData();
|
|
469
|
+
|
|
470
|
+
// A walkthrough has one team, so the board has one row. Fill it in rather
|
|
471
|
+
// than showing a room an empty board they will never see again.
|
|
472
|
+
const rows = runKind === 'walkthrough' ? demonstrationRows : data.market.Standings;
|
|
473
|
+
|
|
474
|
+
return <Board rows={rows} />;
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Most UIs need no changes, and one that ignores this renders exactly as it always has: every session reports `'session'`, and only the walkthrough beside it differs. Reach for it to fill in what a solo run genuinely lacks (rival teams, teammates in a presence list), or to hide something you would rather not spoil before play.
|
|
479
|
+
|
|
480
|
+
**The line to hold:** do not change how the game UI plays. The walkthrough exists to show the room this game, and one that behaves differently shows them a different one.
|
|
481
|
+
|
|
482
|
+
The value is the host's, it is the same one your engine reads as `meta.runKind`, and it does not change for the life of the run, so a decision taken here and one taken in logic cannot disagree.
|
|
483
|
+
|
|
484
|
+
A session UI gets two more pieces. `useSessionUserInterfaceWalkthroughLink()` returns where to open the walkthrough, or `null` until the host has said (every host offers one), and `callbacks.resetWalkthrough()` throws it away and starts it again at tick 0. Gate the control on the link, the way a guide button gates on `useSessionUserInterfaceHostOverlays()`:
|
|
485
|
+
|
|
486
|
+
```tsx
|
|
487
|
+
const walkthrough = useSessionUserInterfaceWalkthroughLink();
|
|
488
|
+
const { resetWalkthrough } = useSessionUserInterfaceCallbacks();
|
|
489
|
+
|
|
490
|
+
{
|
|
491
|
+
walkthrough && (
|
|
492
|
+
<>
|
|
493
|
+
<Button href={walkthrough.url} target="_blank" rel="noreferrer">
|
|
494
|
+
Walkthrough
|
|
495
|
+
</Button>
|
|
496
|
+
<Button onClick={() => void resetWalkthrough()}>Start it again</Button>
|
|
497
|
+
</>
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
## Showing how to join this team
|
|
503
|
+
|
|
504
|
+
Every game UI is told how to join the team it is rendering. Use it to put
|
|
505
|
+
a join code on the screen the participants are actually looking at, rather than having
|
|
506
|
+
the facilitator read a URL out.
|
|
507
|
+
|
|
508
|
+
```typescript
|
|
509
|
+
import { useUserInterfaceOwnTeamJoinLink } from '@seriousgaming/sdk/ui';
|
|
510
|
+
|
|
511
|
+
function JoinCode() {
|
|
512
|
+
const joinLink = useUserInterfaceOwnTeamJoinLink();
|
|
513
|
+
// `null` until the host delivers it, so render nothing for that first frame
|
|
514
|
+
if (!joinLink) return null;
|
|
515
|
+
return <QrCode value={joinLink.joinUrl} />;
|
|
516
|
+
}
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
Points worth knowing:
|
|
520
|
+
|
|
521
|
+
- You always get your own team and only your own team. There is no list, and no
|
|
522
|
+
way to reach another team's link from a game UI
|
|
523
|
+
- Observers get it too, and that is the case the feature exists for. A tablet
|
|
524
|
+
parked on a table showing a team's game UI has no participant of its own, so
|
|
525
|
+
it is opened as an observer: exactly the screen a walk-up join code belongs on
|
|
526
|
+
- Encode `joinUrl` in a QR code, not any longer form you build yourself. The host
|
|
527
|
+
picks which form of the link to send, and it picks the short one because a
|
|
528
|
+
denser code is harder to scan across a table
|
|
529
|
+
- `fallbackJoinUrl` is there for one failure only, a network that blocks the
|
|
530
|
+
shortened link. Offer it on request (a 'link not working?' control), not beside
|
|
531
|
+
the main one with equal weight
|
|
532
|
+
- The session PIN never reaches a game UI. In a session that requires one,
|
|
533
|
+
the code on screen is not enough to join on its own, which is what bounds how
|
|
534
|
+
much a visible code gives away. An operator who wants a self-sufficient walk-up
|
|
535
|
+
code runs the session without a PIN
|
|
536
|
+
|
|
537
|
+
## Letting a team name itself
|
|
538
|
+
|
|
539
|
+
A game UI can rename the team it belongs to. The name it sets is the
|
|
540
|
+
team's real name everywhere, including the host's own chrome and print sheets, so
|
|
541
|
+
a game that offers this keeps no display name of its own alongside it.
|
|
542
|
+
|
|
543
|
+
```typescript
|
|
544
|
+
import { MutationError, useUserInterfaceCallbacks } from '@seriousgaming/sdk/ui';
|
|
545
|
+
|
|
546
|
+
const { renameOwnTeam } = useUserInterfaceCallbacks();
|
|
547
|
+
|
|
548
|
+
try {
|
|
549
|
+
await renameOwnTeam(draftName);
|
|
550
|
+
} catch (error) {
|
|
551
|
+
// A refusal is terminal: explain it rather than retrying
|
|
552
|
+
if (error instanceof MutationError) setRefused(true);
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
Points worth knowing:
|
|
557
|
+
|
|
558
|
+
- **It names no team.** The host works out which team the caller is on, so a
|
|
559
|
+
surface can only ever rename its own. There is no `teamId` argument to get
|
|
560
|
+
wrong
|
|
561
|
+
- **Available everywhere, with nothing to declare.** Game UIs and waiting room
|
|
562
|
+
UIs, participants and observers alike. A game that does not want its teams
|
|
563
|
+
renamed simply does not offer the control
|
|
564
|
+
- **Render the result, not the request.** A facilitator can rename the same team
|
|
565
|
+
from the session UI at the same moment. There is one name per team and the
|
|
566
|
+
last write wins, so read it back from `useUserInterfaceOwnTeam()` rather than trusting what
|
|
567
|
+
you just sent
|
|
568
|
+
- **The host owns the limits.** It trims the name, refuses a blank one, and
|
|
569
|
+
refuses one longer than its cap (60 characters on the reference platform). Each
|
|
570
|
+
arrives as a terminal `denied`, so surface it rather than looping
|
|
571
|
+
- **`teams.defaultNames` becomes a starting point**, not a fixed label, in any
|
|
572
|
+
game that offers this
|
|
573
|
+
|
|
574
|
+
## Displaying the tick timer
|
|
575
|
+
|
|
576
|
+
When a game uses an automatic [tick controller](./tick-controller.md), the host pushes the current schedule to every UI via the `useUserInterfaceTimer` hook, as a `TimerState`. Use this when you want your UI to show its own countdown: for example, a circular progress bar in the corner of a Decisions panel, or a floating 'Next tick in 12s' badge on a results screen.
|
|
577
|
+
|
|
578
|
+
```typescript
|
|
579
|
+
import { useUserInterfaceTimer } from '@seriousgaming/sdk/ui';
|
|
580
|
+
|
|
581
|
+
function NextTickBadge() {
|
|
582
|
+
const { enabled, paused, nextTickAt, intervalMs } = useUserInterfaceTimer();
|
|
583
|
+
|
|
584
|
+
if (!enabled || nextTickAt === null) {
|
|
585
|
+
return null; // manual game, or no tick currently scheduled
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const remainingMs = nextTickAt - Date.now();
|
|
589
|
+
return <span>Next tick in {Math.ceil(remainingMs / 1000)}s</span>;
|
|
590
|
+
}
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
The fields are those of [`TimerState`](../api/sdk-ui.md#timerstate), the one timer shape every surface reads. Durations are milliseconds and end in `Ms`; timestamps are milliseconds since the epoch, in `Date.now()` space, and end in `At`. Which field to reach for:
|
|
594
|
+
|
|
595
|
+
- **`enabled`** is false for a manual-tick game, or once the controller stops scheduling: hide the countdown
|
|
596
|
+
- **`nextTickAt - Date.now()`** is the time left, and **`intervalMs`** the length of the window, for a progress bar. Both are `null` when nothing is scheduled
|
|
597
|
+
- **`paused`** and **`pausedAt`** say whether, and since when, the host has paused ticking
|
|
598
|
+
- **`extendedMs`** is how far a facilitator has moved the current deadline (negative when pulled in), reset with each new tick. Read it to say 'extended by 2m' rather than inferring that from a moved `nextTickAt`
|
|
599
|
+
- **`scheduleStartedAt`** and **`pausedMs`** drive a [simulated game clock](#simulated-game-time)
|
|
600
|
+
|
|
601
|
+
The hook always returns a value: there is no 'loading' state. Before any timer message has arrived, and for games that don't use a tick controller at all, it returns a disabled timer: `enabled` and `paused` false, every timestamp and `intervalMs` `null`, and `pausedMs` and `extendedMs` 0. The `enabled` check is enough to hide the countdown for manual games without any extra branching.
|
|
602
|
+
|
|
603
|
+
### Driving the countdown
|
|
604
|
+
|
|
605
|
+
The hook only fires when the _schedule_ changes: typically once per tick (when the next interval is computed) plus pause/resume events. It does **not** push a new value every second. UIs that want a live ticking countdown must run their own loop locally:
|
|
606
|
+
|
|
607
|
+
```typescript
|
|
608
|
+
import { useUserInterfaceTimer } from '@seriousgaming/sdk/ui';
|
|
609
|
+
import { useEffect, useState } from 'react';
|
|
610
|
+
|
|
611
|
+
function NextTickCountdown() {
|
|
612
|
+
const { enabled, paused, nextTickAt, intervalMs } = useUserInterfaceTimer();
|
|
613
|
+
const [remainingMs, setRemainingMs] = useState(() =>
|
|
614
|
+
nextTickAt === null ? 0 : Math.max(0, nextTickAt - Date.now())
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
useEffect(() => {
|
|
618
|
+
if (!enabled || nextTickAt === null) {
|
|
619
|
+
setRemainingMs(0);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (paused) {
|
|
623
|
+
// Hold the displayed value steady while paused.
|
|
624
|
+
setRemainingMs(Math.max(0, nextTickAt - Date.now()));
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
let raf = 0;
|
|
628
|
+
const loop = () => {
|
|
629
|
+
setRemainingMs(Math.max(0, nextTickAt - Date.now()));
|
|
630
|
+
raf = requestAnimationFrame(loop);
|
|
631
|
+
};
|
|
632
|
+
raf = requestAnimationFrame(loop);
|
|
633
|
+
return () => cancelAnimationFrame(raf);
|
|
634
|
+
}, [enabled, paused, nextTickAt]);
|
|
635
|
+
|
|
636
|
+
if (!enabled || nextTickAt === null) return null;
|
|
637
|
+
|
|
638
|
+
const seconds = Math.ceil(remainingMs / 1000);
|
|
639
|
+
const progress = intervalMs ? 1 - remainingMs / intervalMs : 0;
|
|
640
|
+
return (
|
|
641
|
+
<div>
|
|
642
|
+
<span>{seconds}s</span>
|
|
643
|
+
<progress value={progress} max={1} />
|
|
644
|
+
</div>
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
This pattern is deliberate: by keeping the per-frame state inside one leaf component, only that component re-renders 60 times a second, and the rest of your UI is untouched. If you instead put the live remaining time into a context or shared state, every consumer of that context would re-render on every frame, which can quickly become expensive in a non-trivial UI.
|
|
650
|
+
|
|
651
|
+
### How `nextTickAt` and clocks interact
|
|
652
|
+
|
|
653
|
+
`nextTickAt` is an absolute timestamp in the **same clock-space as `Date.now()`**, so consumer code never needs to compute clock offsets: just subtract. The host is responsible for stamping it in a clock the consumer will recognise:
|
|
654
|
+
|
|
655
|
+
- For dev-server-hosted games (the default), the dev server, the console and your iframe all share `Date.now()`, so `nextTickAt` is correct as-is.
|
|
656
|
+
- For cross-machine deployments, hosts rely on NTP-synced wall clocks. NTP keeps machines within tens of milliseconds of each other, well below the granularity of a second-level countdown.
|
|
657
|
+
|
|
658
|
+
The wrapper does not attempt to re-translate `nextTickAt` on receipt. With only the value and the receiver's `Date.now()` it cannot distinguish 'the host's clock is N ms ahead of mine' from 'this message has been sitting around for N ms', and getting that wrong is exactly how a stored timer state replayed to a remounted iframe would drift. Trusting the timestamp verbatim is what makes the countdown stay accurate when the iframe is remounted.
|
|
659
|
+
|
|
660
|
+
### Pause and resume
|
|
661
|
+
|
|
662
|
+
When the host pauses the timer (e.g. via the dev server's pause button: see [Pausing, resuming and extending](./tick-controller.md#pausing-resuming-and-extending) in the tick controller guide), the hook returns `{ enabled: true, paused: true, nextTickAt: null, intervalMs: null }`. The `nextTickAt: null` tells you there is no scheduled fire-time to count down toward; freeze your displayed value rather than letting it tick to zero. When the host resumes, a new schedule is pushed and the countdown picks up from the new `nextTickAt`.
|
|
663
|
+
|
|
664
|
+
If you want the displayed value to _freeze_ at the moment of pause (rather than disappear), capture the last non-null `nextTickAt` in a ref:
|
|
665
|
+
|
|
666
|
+
```typescript
|
|
667
|
+
const lastScheduledRef = useRef<number | null>(null);
|
|
668
|
+
useEffect(() => {
|
|
669
|
+
if (nextTickAt !== null) lastScheduledRef.current = nextTickAt;
|
|
670
|
+
}, [nextTickAt]);
|
|
671
|
+
|
|
672
|
+
const displayTick = nextTickAt ?? lastScheduledRef.current;
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
### Simulated game time
|
|
676
|
+
|
|
677
|
+
If your game compresses real time into simulated time (e.g. 1 real second = 1 simulated hour), you need a clock that advances with wall time but freezes during pauses. The `pausedAt`, `pausedMs`, and `scheduleStartedAt` fields make this possible without any client-side persistence:
|
|
678
|
+
|
|
679
|
+
```typescript
|
|
680
|
+
import { useUserInterfaceTimer } from '@seriousgaming/sdk/ui';
|
|
681
|
+
|
|
682
|
+
const COMPRESSION = 3600; // 1 real second = 1 simulated hour
|
|
683
|
+
|
|
684
|
+
function SimulatedClock() {
|
|
685
|
+
const { scheduleStartedAt, pausedMs, pausedAt } = useUserInterfaceTimer();
|
|
686
|
+
|
|
687
|
+
if (scheduleStartedAt === null) return null;
|
|
688
|
+
|
|
689
|
+
// Effective elapsed time = wall time minus all pauses (completed + current)
|
|
690
|
+
const effectiveElapsedMs =
|
|
691
|
+
(Date.now() - scheduleStartedAt)
|
|
692
|
+
- pausedMs
|
|
693
|
+
- (pausedAt ? Date.now() - pausedAt : 0);
|
|
694
|
+
|
|
695
|
+
const simMinutes = Math.floor((effectiveElapsedMs * COMPRESSION) / 60_000);
|
|
696
|
+
const hours = Math.floor(simMinutes / 60);
|
|
697
|
+
const mins = simMinutes % 60;
|
|
698
|
+
return <span>{hours}h {mins}m</span>;
|
|
699
|
+
}
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
`pausedMs` accumulates the duration of all _completed_ pauses. It does not include the pause currently in progress, which is derived live from `pausedAt`. This avoids the host having to push updates during a pause while still giving clients a fully accurate value at any instant.
|
|
703
|
+
|
|
704
|
+
Both `pausedMs` and `scheduleStartedAt` reset when the tick controller disables ticking (returns `enabled: false` or `null`). They are **not** reset when the game reaches max ticks or is marked complete, so UIs can still display the final session time after the game ends.
|
|
705
|
+
|
|
706
|
+
## Remembering a viewer's choice
|
|
707
|
+
|
|
708
|
+
A viewer's own choices, the tab they had open or a panel they collapsed, should survive a reload. `useViewerStorage` is `useState` that the browser remembers:
|
|
709
|
+
|
|
710
|
+
```tsx
|
|
711
|
+
import { useViewerStorage } from '@seriousgaming/sdk/ui';
|
|
712
|
+
|
|
713
|
+
const [tab, setTab] = useViewerStorage('tab', 0);
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
Do not key `localStorage` by team id yourself: every session's first team is `team-1`, so one session's values turn up in the next. The hook keys each value by the session (an opaque `sessionKey` the host sends every surface on connect, different for every session and for a walkthrough), the surface (a game UI and its waiting room share, as both are the player's), and the viewer (the participant, or the role where there is none). It works on every surface. Every storage access is guarded, so a browser that blocks storage keeps the value in memory for the page's life instead of throwing, and other tabs follow a change.
|
|
717
|
+
|
|
718
|
+
It is per browser and never shared: anything the game or other players need belongs in context. In tests, the `render*` helpers take a `sessionKey` option. See [Remember a viewer's choice](../recipes/remember-a-viewers-choice.md).
|
|
719
|
+
|
|
720
|
+
## Building components
|
|
721
|
+
|
|
722
|
+
Create React components that display game data:
|
|
723
|
+
|
|
724
|
+
```typescript
|
|
725
|
+
import { useUserInterfaceData } from '@seriousgaming/sdk/ui';
|
|
726
|
+
import { LineChart, Line, XAxis, YAxis } from 'recharts';
|
|
727
|
+
import type { Context, Settings } from '../types';
|
|
728
|
+
|
|
729
|
+
function InventoryChart() {
|
|
730
|
+
const { data } = useUserInterfaceData<Context, Settings>();
|
|
731
|
+
|
|
732
|
+
const { Stock } = data.inventory;
|
|
733
|
+
|
|
734
|
+
const chartData = [
|
|
735
|
+
{ tick: 1, stock: 100 },
|
|
736
|
+
{ tick: 2, stock: 95 },
|
|
737
|
+
{ tick: 3, stock: Stock }
|
|
738
|
+
];
|
|
739
|
+
|
|
740
|
+
return (
|
|
741
|
+
<LineChart width={600} height={300} data={chartData}>
|
|
742
|
+
<XAxis dataKey="tick" />
|
|
743
|
+
<YAxis />
|
|
744
|
+
<Line type="monotone" dataKey="stock" stroke="#8884d8" />
|
|
745
|
+
</LineChart>
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export default InventoryChart;
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
## Using internationalisation
|
|
753
|
+
|
|
754
|
+
The wrapper sets `lang` and `dir` on the document root from the locale (`dir="rtl"` for Arabic, Hebrew, Persian, Urdu and any other right-to-left script) and keeps them in step when the facilitator changes language, so a surface does not set them itself. It also sets the root's `color-scheme` to the theme's mode, so scrollbars and native form controls follow a dark theme without MUI.
|
|
755
|
+
|
|
756
|
+
Use React i18next for multi-language support:
|
|
757
|
+
|
|
758
|
+
```typescript
|
|
759
|
+
import { useTranslation } from 'react-i18next';
|
|
760
|
+
import { useUserInterfaceLocale } from '@seriousgaming/sdk/ui';
|
|
761
|
+
|
|
762
|
+
function Dashboard() {
|
|
763
|
+
const { t } = useTranslation();
|
|
764
|
+
const { locale } = useUserInterfaceLocale();
|
|
765
|
+
|
|
766
|
+
return (
|
|
767
|
+
<div>
|
|
768
|
+
<h1>{t('dashboard.title')}</h1>
|
|
769
|
+
<p>{t('dashboard.welcome')}</p>
|
|
770
|
+
<p>Current language: {locale}</p>
|
|
771
|
+
</div>
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
Update your `src/i18n.ts`:
|
|
777
|
+
|
|
778
|
+
```typescript
|
|
779
|
+
import i18n from 'i18next';
|
|
780
|
+
import { initReactI18next } from 'react-i18next';
|
|
781
|
+
|
|
782
|
+
const resources = {
|
|
783
|
+
en: {
|
|
784
|
+
translation: {
|
|
785
|
+
'dashboard.title': 'Dashboard',
|
|
786
|
+
'dashboard.welcome': 'Welcome',
|
|
787
|
+
'inventory.label': 'Stock Level',
|
|
788
|
+
'revenue.label': 'Revenue',
|
|
789
|
+
},
|
|
790
|
+
},
|
|
791
|
+
es: {
|
|
792
|
+
translation: {
|
|
793
|
+
'dashboard.title': 'Panel de Control',
|
|
794
|
+
'dashboard.welcome': 'Bienvenido',
|
|
795
|
+
'inventory.label': 'Nivel de Stock',
|
|
796
|
+
'revenue.label': 'Ingresos',
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
i18n.use(initReactI18next).init({
|
|
802
|
+
resources,
|
|
803
|
+
lng: 'en',
|
|
804
|
+
interpolation: { escapeValue: false },
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
export default i18n;
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
## Linking to static assets
|
|
811
|
+
|
|
812
|
+
User interfaces run inside an iframe served at a nested path (e.g.,
|
|
813
|
+
`/user-interfaces/ui/`). Because of this, **absolute URLs
|
|
814
|
+
like `/my-file.pdf` will not resolve to your assets**: they escape the iframe
|
|
815
|
+
and hit the shell app's origin instead.
|
|
816
|
+
|
|
817
|
+
Always use **relative paths** when linking to files in your UI's directory:
|
|
818
|
+
|
|
819
|
+
```typescript
|
|
820
|
+
// Wrong: escapes the iframe, loads the shell app's index.html
|
|
821
|
+
window.open('/Briefing.pdf');
|
|
822
|
+
|
|
823
|
+
// Correct: resolves relative to your UI's index.html
|
|
824
|
+
window.open('./Briefing.pdf');
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
This applies to any reference from your UI code to static files: `<a href>`,
|
|
828
|
+
`<img src>`, `<embed src>`, `window.open()`, `fetch()`, etc. The same rule
|
|
829
|
+
holds in both the dev server and production builds.
|
|
830
|
+
|
|
831
|
+
In production your UI also runs under a strict content security policy: every
|
|
832
|
+
asset must be bundled into your build (no external CDNs, no Google Fonts, no
|
|
833
|
+
blob-URL workers), and outbound network calls go through an engine, an endpoint,
|
|
834
|
+
or a host capability. The dev server mirrors this policy, so violations
|
|
835
|
+
surface locally. See [Production security constraints](./production-constraints.md)
|
|
836
|
+
for the full policy and the do's and don'ts.
|
|
837
|
+
|
|
838
|
+
**Where to put static files:** Place them in your UI's directory alongside
|
|
839
|
+
`index.html` (or in a subdirectory). For the default scaffold structure, that
|
|
840
|
+
means inside `ui/`:
|
|
841
|
+
|
|
842
|
+
```
|
|
843
|
+
ui/
|
|
844
|
+
├── index.html
|
|
845
|
+
├── Briefing.pdf ← ./Briefing.pdf
|
|
846
|
+
├── public/
|
|
847
|
+
│ └── logo.png ← ./public/logo.png
|
|
848
|
+
└── src/
|
|
849
|
+
└── App.tsx
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
## File uploads
|
|
853
|
+
|
|
854
|
+
User interfaces can upload files (PDFs, images, CSV, etc.) using the `uploadBlob` callback and display them using `getAssetUrl`. Uploaded files and scenario-bundled files share a single asset reference scheme (see [Asset references](../guides/file-handling.md#asset-references) for the full picture).
|
|
855
|
+
|
|
856
|
+
### Uploading a file
|
|
857
|
+
|
|
858
|
+
```typescript
|
|
859
|
+
import { useUserInterfaceCallbacks, useUserInterfaceData } from '@seriousgaming/sdk/ui';
|
|
860
|
+
import type { Context, Settings } from '../types';
|
|
861
|
+
|
|
862
|
+
function DocumentUpload() {
|
|
863
|
+
const callbacks = useUserInterfaceCallbacks();
|
|
864
|
+
const { setData } = useUserInterfaceData<Context, Settings>();
|
|
865
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
866
|
+
|
|
867
|
+
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
868
|
+
const file = e.target.files?.[0];
|
|
869
|
+
if (!file || !callbacks.uploadBlob) return;
|
|
870
|
+
|
|
871
|
+
try {
|
|
872
|
+
setIsUploading(true);
|
|
873
|
+
const reference = await callbacks.uploadBlob(file);
|
|
874
|
+
setData({ 'upload': { DocumentRef: reference } });
|
|
875
|
+
} finally {
|
|
876
|
+
setIsUploading(false);
|
|
877
|
+
e.target.value = '';
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
return (
|
|
882
|
+
<input type="file" accept=".pdf,.csv,.txt" onChange={handleUpload} disabled={isUploading} />
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
The capability holding the reference must be listed in the UI's `writes`. The maximum file size is **20 MB**.
|
|
888
|
+
|
|
889
|
+
### Displaying a file
|
|
890
|
+
|
|
891
|
+
Use `useAssetUrl` to get a URL suitable for `<img>`, `<embed>`, or `<video>`. It resolves uploaded blobs and scenario-bundled assets alike, and returns `undefined` for an absent reference, so there is nothing to guard:
|
|
892
|
+
|
|
893
|
+
```typescript
|
|
894
|
+
import { useAssetUrl } from '@seriousgaming/sdk/ui';
|
|
895
|
+
|
|
896
|
+
function DocumentViewer() {
|
|
897
|
+
const { data } = useUserInterfaceData<Context, Settings>();
|
|
898
|
+
const src = useAssetUrl(data.upload?.DocumentRef);
|
|
899
|
+
if (!src) return null;
|
|
900
|
+
|
|
901
|
+
return (
|
|
902
|
+
<embed
|
|
903
|
+
src={src}
|
|
904
|
+
type="application/pdf"
|
|
905
|
+
style={{ width: '100%', height: '500px', border: 'none' }}
|
|
906
|
+
/>
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
The capability holding the reference must be listed in the UI's `reads`. `useAssetUrl` works on every surface; the `getAssetUrl` callback does the same job outside a component.
|
|
912
|
+
|
|
913
|
+
For the full reference, including engine access via `readAsset`, blob scopes, CLI upload, and supported file types, see [File handling](./file-handling.md).
|
|
914
|
+
|
|
915
|
+
## Reading an asset's contents
|
|
916
|
+
|
|
917
|
+
To render what is in a file rather than link to it, such as a briefing, a JSON table of scenario prose, or a CSV, use `useAsset`. It fetches the asset, reads the body `as` `'json'`, `'text'` or `'blob'`, and returns `{ data, error, loading }`:
|
|
918
|
+
|
|
919
|
+
```tsx
|
|
920
|
+
import { useAsset } from '@seriousgaming/sdk/ui';
|
|
921
|
+
|
|
922
|
+
function Briefing() {
|
|
923
|
+
const { data, error, loading } = useAsset('shared:briefing/briefing.md', { as: 'text' });
|
|
924
|
+
if (loading) return <Spinner />;
|
|
925
|
+
if (error) return <Alert>{error.message}</Alert>;
|
|
926
|
+
return <Markdown>{data}</Markdown>;
|
|
927
|
+
}
|
|
928
|
+
```
|
|
929
|
+
|
|
930
|
+
It prefers a copy in the surface's locale, held in a directory named for the locale just before the file name, and falls back to the reference as given. In `fr-CA`, `shared:briefing/briefing.md` tries `shared:briefing/fr-CA/briefing.md`, then `shared:briefing/fr/briefing.md`, then `shared:briefing/briefing.md`:
|
|
931
|
+
|
|
932
|
+
```
|
|
933
|
+
shared-assets/
|
|
934
|
+
briefing/
|
|
935
|
+
briefing.md ← the untranslated text, and the fallback
|
|
936
|
+
fr/briefing.md
|
|
937
|
+
ar/briefing.md
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
Pass `locale` to look in a different one, or `locale: false` to read the reference alone. A `blob:` reference is always read as given. The hook refetches when the reference or the locale changes, so it follows a mid-session language switch, and an answer to a reference that has since changed is discarded. For JSON, give the type: `useAsset<Briefing>(reference, { as: 'json' })`.
|
|
941
|
+
|
|
942
|
+
## Theming and styling
|
|
943
|
+
|
|
944
|
+
Serious Gaming provides a theming system that allows your user interfaces to adapt to the host environment's look and feel. This is handled through the `@seriousgaming/sdk` package.
|
|
945
|
+
|
|
946
|
+
### Accessing theme information
|
|
947
|
+
|
|
948
|
+
You can access the current theme information within your components using the `useUserInterfaceTheme` hook.
|
|
949
|
+
|
|
950
|
+
```typescript
|
|
951
|
+
import { useUserInterfaceTheme } from '@seriousgaming/sdk/ui';
|
|
952
|
+
|
|
953
|
+
function MyComponent() {
|
|
954
|
+
const theme = useUserInterfaceTheme();
|
|
955
|
+
// theme.mode will be 'light' or 'dark'
|
|
956
|
+
// theme.light and theme.dark will contain color palettes
|
|
957
|
+
|
|
958
|
+
return (
|
|
959
|
+
<div style={% raw %}{{{% endraw %} background: theme.mode === 'light' ? theme.light.primary : theme.dark.primary {% raw %}}}{% endraw %}>
|
|
960
|
+
My Component
|
|
961
|
+
</div>
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
### Theme object structure
|
|
967
|
+
|
|
968
|
+
The `theme` object returned by `useUserInterfaceTheme` has the following structure:
|
|
969
|
+
|
|
970
|
+
```typescript
|
|
971
|
+
type UserInterfaceTheme = {
|
|
972
|
+
mode: 'dark' | 'light';
|
|
973
|
+
dark: UserInterfaceModeColors;
|
|
974
|
+
light: UserInterfaceModeColors;
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
type UserInterfaceModeColors = {
|
|
978
|
+
primary: string;
|
|
979
|
+
secondary: string;
|
|
980
|
+
success: string;
|
|
981
|
+
error: string;
|
|
982
|
+
warning: string;
|
|
983
|
+
info: string;
|
|
984
|
+
contrastText: {
|
|
985
|
+
primary: string;
|
|
986
|
+
secondary: string;
|
|
987
|
+
success: string;
|
|
988
|
+
error: string;
|
|
989
|
+
warning: string;
|
|
990
|
+
info: string;
|
|
991
|
+
};
|
|
992
|
+
};
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
- `mode`: Indicates whether the current theme is `'light'` or `'dark'`.
|
|
996
|
+
- `light`: An object containing the colour palette for the light theme.
|
|
997
|
+
- `dark`: An object containing the colour palette for the dark theme.
|
|
998
|
+
- `contrastText`: The text colour to draw on each palette colour, an opaque `#rrggbb` the host guarantees meets WCAG AA (4.5:1) against it. Use it rather than computing your own: the theme's author may have chosen one, and the host keeps it when it passes.
|
|
999
|
+
|
|
1000
|
+
```tsx
|
|
1001
|
+
const colors = theme.mode === 'light' ? theme.light : theme.dark;
|
|
1002
|
+
|
|
1003
|
+
<span style={% raw %}{{{% endraw %} background: colors.primary, color: colors.contrastText.primary {% raw %}}}{% endraw %}>Live</span>
|
|
1004
|
+
```
|
|
1005
|
+
|
|
1006
|
+
Passing `contrastText` through to Material-UI, as the template's `theme.ts` does (below), means every contained button and chip already uses it.
|
|
1007
|
+
|
|
1008
|
+
### Reading the host's theme preference
|
|
1009
|
+
|
|
1010
|
+
`useUserInterfaceTheme` gives you the resolved palette, but `theme.mode` only tells you the colours to paint right now. It cannot tell you whether the host is following the operating system or has been pinned to a specific scheme. When you need that distinction, read the host's preference with the `useUserInterfaceThemePreference()` hook. It returns `'light'`, `'dark'`, or `'system'` and re-renders when the preference changes, exactly like `useUserInterfaceTheme()`:
|
|
1011
|
+
|
|
1012
|
+
```typescript
|
|
1013
|
+
import { useUserInterfaceThemePreference } from '@seriousgaming/sdk/ui';
|
|
1014
|
+
|
|
1015
|
+
function ThemePreferenceBadge() {
|
|
1016
|
+
const preference = useUserInterfaceThemePreference();
|
|
1017
|
+
return <span>Following: {preference}</span>;
|
|
1018
|
+
}
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
Each UI surface has its own hook: `useUserInterfaceThemePreference`, `useSessionUserInterfaceThemePreference`, and `useSettingsEditorThemePreference`.
|
|
1022
|
+
|
|
1023
|
+
A UI can also change the preference with the `setThemePreference` callback. The host applies it, persists it, and broadcasts the change back, so every other UI (and every theme-preference hook) sees it:
|
|
1024
|
+
|
|
1025
|
+
```typescript
|
|
1026
|
+
import { useUserInterfaceCallbacks } from '@seriousgaming/sdk/ui';
|
|
1027
|
+
|
|
1028
|
+
function ThemeSwitcher() {
|
|
1029
|
+
const callbacks = useUserInterfaceCallbacks();
|
|
1030
|
+
return (
|
|
1031
|
+
<>
|
|
1032
|
+
<button onClick={() => callbacks.setThemePreference('light')}>Light</button>
|
|
1033
|
+
<button onClick={() => callbacks.setThemePreference('dark')}>Dark</button>
|
|
1034
|
+
<button onClick={() => callbacks.setThemePreference('system')}>System</button>
|
|
1035
|
+
</>
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
`setThemePreference` is available on every UI surface (game, waiting room, session, configuration). No `uses` declaration is needed, because theme preference is not gated.
|
|
1041
|
+
|
|
1042
|
+
### Integrating with Material-UI
|
|
1043
|
+
|
|
1044
|
+
The default user interface template uses [Material-UI](https://mui.com/) for its component library.
|
|
1045
|
+
|
|
1046
|
+
Here's how to create a dynamic theme in your `App.tsx`:
|
|
1047
|
+
|
|
1048
|
+
```typescript
|
|
1049
|
+
// src/App.tsx
|
|
1050
|
+
import React, { useMemo } from 'react';
|
|
1051
|
+
import { createTheme, ThemeProvider } from '@mui/material';
|
|
1052
|
+
import { useUserInterfaceTheme } from '@seriousgaming/sdk/ui';
|
|
1053
|
+
import MyComponent from './MyComponent';
|
|
1054
|
+
|
|
1055
|
+
function App() {
|
|
1056
|
+
const theme = useUserInterfaceTheme();
|
|
1057
|
+
|
|
1058
|
+
const customTheme = useMemo(() => {
|
|
1059
|
+
const themeColors = theme.mode === 'light' ? theme.light : theme.dark;
|
|
1060
|
+
return createTheme({
|
|
1061
|
+
palette: {
|
|
1062
|
+
mode: theme.mode,
|
|
1063
|
+
primary: { main: themeColors.primary, contrastText: themeColors.contrastText.primary },
|
|
1064
|
+
secondary: { main: themeColors.secondary, contrastText: themeColors.contrastText.secondary },
|
|
1065
|
+
success: { main: themeColors.success, contrastText: themeColors.contrastText.success },
|
|
1066
|
+
error: { main: themeColors.error, contrastText: themeColors.contrastText.error },
|
|
1067
|
+
warning: { main: themeColors.warning, contrastText: themeColors.contrastText.warning },
|
|
1068
|
+
info: { main: themeColors.info, contrastText: themeColors.contrastText.info },
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
}, [theme]);
|
|
1072
|
+
|
|
1073
|
+
return (
|
|
1074
|
+
<ThemeProvider theme={customTheme}>
|
|
1075
|
+
<MyComponent />
|
|
1076
|
+
</ThemeProvider>
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
By using `useMemo`, you ensure that the theme object is only recreated when the theme context from Serious Gaming changes, which is important for performance.
|
|
1082
|
+
|
|
1083
|
+
## Sizing to the viewport on mobile
|
|
1084
|
+
|
|
1085
|
+
The host sizes your UI's iframe to the visible viewport, so size your UI to fill its container rather than the browser viewport: prefer `height: 100%` (down through `html`, `body`, and the root element) with flex or grid layouts over viewport units.
|
|
1086
|
+
|
|
1087
|
+
If you do reach for viewport units, use `dvh` with a `vh` fallback. On iOS WebKit `100vh` resolves to the tall (URL-bar-collapsed) viewport even while the bar is showing, so a `100vh` element runs off the bottom of the screen; `100dvh` tracks the live visible viewport:
|
|
1088
|
+
|
|
1089
|
+
```css
|
|
1090
|
+
min-height: 100vh; /* fallback for older browsers */
|
|
1091
|
+
min-height: 100dvh; /* tracks the live viewport on iOS */
|
|
1092
|
+
```
|
|
1093
|
+
|
|
1094
|
+
In MUI `sx`, where you cannot repeat a key, express the fallback with `@supports`:
|
|
1095
|
+
|
|
1096
|
+
```ts
|
|
1097
|
+
sx={{ minHeight: '100vh', '@supports (height: 100dvh)': { minHeight: '100dvh' } }}
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
Embedded in the host this rarely bites, because an iframe has no collapsing browser chrome and `vh` resolves to the iframe's own height. The `dvh` form keeps your UI correct if it is ever opened standalone in a mobile browser, and the scaffold templates already follow it.
|
|
1101
|
+
|
|
1102
|
+
## Using LLMs in user interfaces
|
|
1103
|
+
|
|
1104
|
+
User interfaces cannot call `generateText` (or `extractText` or `generateSpeech`) directly. A UI reaches these services through an [endpoint](./endpoints.md): the endpoint wraps the callback and runs server-side, and the UI invokes it through `callbacks.endpoints['<endpoint-id>']`. This keeps credential-holding calls off the untrusted iframe. To create one, follow [Create an endpoint](../recipes/create-endpoint.md). See [Call an endpoint from a UI](../recipes/call-endpoint-from-ui.md) and the [LLM integration guide](llm-integration.md) for the full pattern.
|
|
1105
|
+
|
|
1106
|
+
### The endpoint
|
|
1107
|
+
|
|
1108
|
+
The endpoint bundle declares `"uses": ["generateText"]` and runs the LLM call:
|
|
1109
|
+
|
|
1110
|
+
```typescript
|
|
1111
|
+
// endpoints/chat/logic/logic.ts
|
|
1112
|
+
import { getText } from '@seriousgaming/sdk';
|
|
1113
|
+
import type { Handler } from '../types';
|
|
1114
|
+
|
|
1115
|
+
export const Handle: Handler = async ({ request, callbacks }) => {
|
|
1116
|
+
const response = await callbacks.generateText({
|
|
1117
|
+
messages: request.messages,
|
|
1118
|
+
systemPrompt:
|
|
1119
|
+
request.systemPrompt ?? 'You are a helpful business advisor. Provide concise guidance.',
|
|
1120
|
+
});
|
|
1121
|
+
return { reply: getText(response) };
|
|
1122
|
+
};
|
|
1123
|
+
```
|
|
1124
|
+
|
|
1125
|
+
The UI bundle declares the endpoint in its `endpoints` array. A UI bundle has no `uses` field, and the build refuses one.
|
|
1126
|
+
|
|
1127
|
+
```json
|
|
1128
|
+
{
|
|
1129
|
+
"definition": {
|
|
1130
|
+
"endpoints": ["chat"]
|
|
1131
|
+
},
|
|
1132
|
+
"rootDirectory": ".",
|
|
1133
|
+
"ui": { "entrypoint": "index.html" }
|
|
1134
|
+
}
|
|
1135
|
+
```
|
|
1136
|
+
|
|
1137
|
+
### Calling the endpoint from a component
|
|
1138
|
+
|
|
1139
|
+
Access `callbacks.endpoints` with the `useUserInterfaceCallbacks` hook. Persist the conversation in a capability so it survives across ticks:
|
|
1140
|
+
|
|
1141
|
+
```tsx
|
|
1142
|
+
import { useState } from 'react';
|
|
1143
|
+
import { useUserInterfaceCallbacks, useUserInterfaceData } from '@seriousgaming/sdk/ui';
|
|
1144
|
+
import type { Context, Settings } from '../types';
|
|
1145
|
+
import type { Callbacks } from '../types';
|
|
1146
|
+
|
|
1147
|
+
function ChatAssistant() {
|
|
1148
|
+
const { data, setData } = useUserInterfaceData<Context, Settings>();
|
|
1149
|
+
const callbacks = useUserInterfaceCallbacks() as Callbacks;
|
|
1150
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
1151
|
+
|
|
1152
|
+
const messages = data.chathistory.Messages;
|
|
1153
|
+
|
|
1154
|
+
const handleSendMessage = async (userMessage: string) => {
|
|
1155
|
+
if (!userMessage.trim()) return;
|
|
1156
|
+
setIsLoading(true);
|
|
1157
|
+
try {
|
|
1158
|
+
const newMessages = [...messages, { role: 'user' as const, content: userMessage }];
|
|
1159
|
+
const { reply } = await callbacks.endpoints['chat']({
|
|
1160
|
+
messages: newMessages,
|
|
1161
|
+
});
|
|
1162
|
+
await setData({
|
|
1163
|
+
chathistory: {
|
|
1164
|
+
Messages: [...newMessages, { role: 'assistant' as const, content: reply }],
|
|
1165
|
+
},
|
|
1166
|
+
});
|
|
1167
|
+
} finally {
|
|
1168
|
+
setIsLoading(false);
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
return (
|
|
1173
|
+
<div>
|
|
1174
|
+
{messages.map((msg, idx) => (
|
|
1175
|
+
<div key={idx} className={msg.role}>
|
|
1176
|
+
{msg.content}
|
|
1177
|
+
</div>
|
|
1178
|
+
))}
|
|
1179
|
+
<input
|
|
1180
|
+
type="text"
|
|
1181
|
+
placeholder="Type your message..."
|
|
1182
|
+
onKeyPress={(e) => {
|
|
1183
|
+
if (e.key === 'Enter' && !isLoading) {
|
|
1184
|
+
handleSendMessage(e.currentTarget.value);
|
|
1185
|
+
e.currentTarget.value = '';
|
|
1186
|
+
}
|
|
1187
|
+
}}
|
|
1188
|
+
disabled={isLoading}
|
|
1189
|
+
/>
|
|
1190
|
+
{isLoading && <p>Loading...</p>}
|
|
1191
|
+
</div>
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
### Localising prompts
|
|
1197
|
+
|
|
1198
|
+
Translate the system prompt in the UI with i18n and pass it to the endpoint in the request. The endpoint forwards it to `generateText`:
|
|
1199
|
+
|
|
1200
|
+
```tsx
|
|
1201
|
+
import { useTranslation } from 'react-i18next';
|
|
1202
|
+
|
|
1203
|
+
const { t } = useTranslation();
|
|
1204
|
+
const { reply } = await callbacks.endpoints['chat']({
|
|
1205
|
+
messages: newMessages,
|
|
1206
|
+
systemPrompt: t('chatSystemPrompt'),
|
|
1207
|
+
});
|
|
1208
|
+
```
|
|
1209
|
+
|
|
1210
|
+
Update `src/i18n.ts`:
|
|
1211
|
+
|
|
1212
|
+
```typescript
|
|
1213
|
+
const resources = {
|
|
1214
|
+
en: {
|
|
1215
|
+
translation: {
|
|
1216
|
+
chatSystemPrompt: 'You are a helpful assistant for a business game.',
|
|
1217
|
+
askForAdvice: 'Ask for advice...',
|
|
1218
|
+
},
|
|
1219
|
+
},
|
|
1220
|
+
es: {
|
|
1221
|
+
translation: {
|
|
1222
|
+
chatSystemPrompt: 'Eres un asistente útil para un juego de simulación empresarial.',
|
|
1223
|
+
askForAdvice: 'Pedir consejo...',
|
|
1224
|
+
},
|
|
1225
|
+
},
|
|
1226
|
+
};
|
|
1227
|
+
```
|
|
1228
|
+
|
|
1229
|
+
### Markdown rendering in chat
|
|
1230
|
+
|
|
1231
|
+
Render markdown replies with `react-markdown`:
|
|
1232
|
+
|
|
1233
|
+
```typescript
|
|
1234
|
+
import { useUserInterfaceCallbacks } from '@seriousgaming/sdk/ui';
|
|
1235
|
+
import ReactMarkdown from 'react-markdown';
|
|
1236
|
+
import { Box, styled } from '@mui/material';
|
|
1237
|
+
|
|
1238
|
+
const StyledMarkdown = styled(Box)(({ theme }) => ({
|
|
1239
|
+
'& code': {
|
|
1240
|
+
backgroundColor: theme.palette.action.hover,
|
|
1241
|
+
padding: '2px 6px',
|
|
1242
|
+
borderRadius: '4px',
|
|
1243
|
+
fontFamily: 'monospace'
|
|
1244
|
+
},
|
|
1245
|
+
'& pre': {
|
|
1246
|
+
backgroundColor: theme.palette.action.hover,
|
|
1247
|
+
padding: theme.spacing(1),
|
|
1248
|
+
borderRadius: '4px',
|
|
1249
|
+
overflow: 'auto'
|
|
1250
|
+
}
|
|
1251
|
+
}));
|
|
1252
|
+
|
|
1253
|
+
function ChatMessage({ content }: { content: string }) {
|
|
1254
|
+
return (
|
|
1255
|
+
<StyledMarkdown>
|
|
1256
|
+
<ReactMarkdown>{content}</ReactMarkdown>
|
|
1257
|
+
</StyledMarkdown>
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
### Error handling
|
|
1263
|
+
|
|
1264
|
+
A failed endpoint call rejects with an `EndpointError`, the endpoint counterpart of `MutationError`. `error.kind` (shorthand for `error.outcome.kind`) says why: the `MutationOutcome` kinds above, plus `invalid-request` (the request failed `requestSchema` and the handler never ran), `handler-error` (the handler threw or overran its time limit) and `invalid-response` (it returned something its declaration forbids). `error.applied` lists the endpoint's effects that had already landed, and `error.failedStep` names the one that failed, so you can tell a call that changed nothing from one that saved its write and then failed on an action:
|
|
1265
|
+
|
|
1266
|
+
```typescript
|
|
1267
|
+
import { EndpointError } from '@seriousgaming/sdk/ui';
|
|
1268
|
+
|
|
1269
|
+
try {
|
|
1270
|
+
const { reply } = await callbacks.endpoints['chat']({ messages });
|
|
1271
|
+
setResponse(reply);
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
if (error instanceof EndpointError && error.kind === 'handler-error') {
|
|
1274
|
+
setError(t('chatUnavailable'));
|
|
1275
|
+
} else {
|
|
1276
|
+
setError(error instanceof Error ? error.message : String(error));
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
```
|
|
1280
|
+
|
|
1281
|
+
An empty `applied` means nothing changed. As with a mutation, never retry an endpoint call automatically: a `conflict` means a person may try again. See [Call an endpoint from a UI](../recipes/call-endpoint-from-ui.md#steps) for every kind.
|
|
1282
|
+
|
|
1283
|
+
### Putting a chat UI together
|
|
1284
|
+
|
|
1285
|
+
The sections above are the parts of a complete chat surface. Beyond them, a chat that feels finished usually adds:
|
|
1286
|
+
|
|
1287
|
+
- Message history kept in the component's state, or in a capability when it must survive a reload, and sent with each call
|
|
1288
|
+
- A loading state while the call is in flight, with the send button disabled
|
|
1289
|
+
- Scrolling to the latest message when one arrives
|
|
1290
|
+
|
|
1291
|
+
## Best practices
|
|
1292
|
+
|
|
1293
|
+
1. **Use type safety** - Import generated types from `./types`
|
|
1294
|
+
- ✅ `useUserInterfaceData<Context, Settings>()`
|
|
1295
|
+
- ❌ `useUserInterfaceData<any, any>()`
|
|
1296
|
+
|
|
1297
|
+
2. **Access data via destructuring** - Get data, settings, and setData from the hook
|
|
1298
|
+
- ✅ `const { data, settings, setData } = useUserInterfaceData<Context, Settings>()`
|
|
1299
|
+
- ❌ Trying to access missing hooks like `useUserInterfaceContext`
|
|
1300
|
+
|
|
1301
|
+
3. **Declare every capability you render** - `data` carries exactly what the bundle declares
|
|
1302
|
+
- ✅ Add the capability to `reads` (or `writes`) and read `data.inventory.Stock` directly
|
|
1303
|
+
- ❌ Render a capability the bundle never declared and guard the `undefined` with `?.`
|
|
1304
|
+
|
|
1305
|
+
4. **Respect write boundaries** - Only update capabilities in `writes`
|
|
1306
|
+
- ✅ Use `setData` for capabilities in `writes`
|
|
1307
|
+
- ❌ Try to update read-only capabilities
|
|
1308
|
+
|
|
1309
|
+
5. **Responsive design** - Ensure UIs work on different screen sizes
|
|
1310
|
+
- ✅ Use flexbox/grid, size to the container, test on mobile
|
|
1311
|
+
- ❌ Fixed widths, desktop-only design, `100vh` without a `dvh` fallback
|
|
1312
|
+
|
|
1313
|
+
6. **Performance** - Use React hooks efficiently
|
|
1314
|
+
- ✅ `useEffect` for side effects, memoisation for expensive renders
|
|
1315
|
+
- ❌ Recalculate everything on every render
|
|
1316
|
+
|
|
1317
|
+
## Testing UIs
|
|
1318
|
+
|
|
1319
|
+
For component tests with Vitest and the render helpers from `@seriousgaming/sdk/ui/testing`, see [Testing user interfaces](./testing.md#testing-user-interfaces). To see a UI running, start the dev server in your game directory:
|
|
1320
|
+
|
|
1321
|
+
```bash
|
|
1322
|
+
pnpm run sdk dev
|
|
1323
|
+
```
|
|
1324
|
+
|
|
1325
|
+
Your UI appears in the dev server at http://localhost:3000. You can:
|
|
1326
|
+
|
|
1327
|
+
- View context in real time
|
|
1328
|
+
- Step through ticks
|
|
1329
|
+
- Watch UI update with each tick
|
|
1330
|
+
- Switch languages
|
|
1331
|
+
- Undo/redo changes
|
|
1332
|
+
|
|
1333
|
+
## Customising Vite (dev and build)
|
|
1334
|
+
|
|
1335
|
+
When the SDK starts Vite for your UI (whether for the dev server or a production build), you can customise Vite's behaviour by creating a `vite.config.ts` file. The SDK loads it and merges it with its own inline config.
|
|
1336
|
+
|
|
1337
|
+
### Where to put `vite.config.ts`
|
|
1338
|
+
|
|
1339
|
+
**Put it at the project root** (alongside `seriousgaming.sdk.json`). This is the recommended location: one file applies to every UI in the package, and the same file is used by both `sdk dev` and `sdk build`.
|
|
1340
|
+
|
|
1341
|
+
```
|
|
1342
|
+
my-package/
|
|
1343
|
+
├── seriousgaming.sdk.json
|
|
1344
|
+
├── vite.config.ts ← recommended location
|
|
1345
|
+
├── ui/
|
|
1346
|
+
│ ├── index.html
|
|
1347
|
+
│ └── src/
|
|
1348
|
+
└── session/
|
|
1349
|
+
└── ...
|
|
1350
|
+
```
|
|
1351
|
+
|
|
1352
|
+
### Per-UI overrides
|
|
1353
|
+
|
|
1354
|
+
You can also drop a `vite.config.ts` inside an individual `ui/` directory. The SDK loads it in addition to the project-root config and the per-UI file's values override the root's on conflicts. This is useful when one UI needs a specific alias or plugin that would break the others, but in practice the root config is almost always enough, and per-UI files multiply maintenance.
|
|
1355
|
+
|
|
1356
|
+
**Don't keep both a root config and a per-UI config for the same UI unless you really mean to.** If the SDK finds both while building a given UI it prints a warning and asks you to consolidate. Merging still happens so the build doesn't break, but the warning is telling you that the intent is unclear.
|
|
1357
|
+
|
|
1358
|
+
### What merges and what doesn't
|
|
1359
|
+
|
|
1360
|
+
The merge order is:
|
|
1361
|
+
|
|
1362
|
+
```
|
|
1363
|
+
project-root vite.config.ts → ui/vite.config.ts → SDK inline config
|
|
1364
|
+
(baseline) (overrides root) (SDK owns these)
|
|
1365
|
+
```
|
|
1366
|
+
|
|
1367
|
+
**Additive options merge cleanly.** `resolve.dedupe`, `resolve.alias`, `optimizeDeps.include`, `esbuild`, extra `plugins`, and similar fields are not set (or only partially set) by the SDK, so your entries are combined with the SDK's.
|
|
1368
|
+
|
|
1369
|
+
**The SDK wins on structural fields.** `root`, `base`, `build.outDir`, `build.rollupOptions.input`, and the dev-server `server.middlewareMode`/`appType`/`hmr` settings are owned by the SDK and cannot be overridden: trying to set them in `vite.config.ts` has no effect.
|
|
1370
|
+
|
|
1371
|
+
A common use case is preventing duplicate package instances when using third-party React libraries:
|
|
1372
|
+
|
|
1373
|
+
```typescript
|
|
1374
|
+
// vite.config.ts (project root)
|
|
1375
|
+
import { defineConfig } from 'vite';
|
|
1376
|
+
|
|
1377
|
+
export default defineConfig({
|
|
1378
|
+
resolve: {
|
|
1379
|
+
dedupe: ['react', 'react-dom'],
|
|
1380
|
+
},
|
|
1381
|
+
});
|
|
1382
|
+
```
|
|
1383
|
+
|
|
1384
|
+
See [Fix duplicate package errors in a UI](../recipes/fix-duplicate-packages.md) for a full explanation of when and why this is needed.
|
|
1385
|
+
|
|
1386
|
+
## Next steps
|
|
1387
|
+
|
|
1388
|
+
- [Internationalisation guide](./internationalization.md) - Add multi-language support
|
|
1389
|
+
- [Getting started](../getting-started.md) - Run the dev server
|