@zergai/ztc 0.4.0-beta.1 → 0.5.0-beta.1
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/README.md +398 -45
- package/RUST_THIRD_PARTY_LICENSES.txt +1080 -822
- package/THIRD_PARTY_NOTICES.md +273 -12
- package/bin/zmux-darwin-arm64 +0 -0
- package/bin/ztc-audio-darwin-arm64 +0 -0
- package/bin/ztc-capture-darwin-arm64 +0 -0
- package/bin/ztc-computer-darwin-arm64 +0 -0
- package/bin/ztc-llm-darwin-arm64 +0 -0
- package/bin/ztc-tts-darwin-arm64 +0 -0
- package/bin/ztc-tui-darwin-arm64 +0 -0
- package/bin/ztc-zlicky-overlay-darwin-arm64 +0 -0
- package/bin/zterm-darwin-arm64 +0 -0
- package/dist/App.js +1206 -209
- package/dist/agent/agent.d.ts +74 -8
- package/dist/agent/agent.js +1073 -184
- package/dist/agent/backends/account_rotation.d.ts +4 -0
- package/dist/agent/backends/account_rotation.js +8 -2
- package/dist/agent/backends/anthropic.d.ts +13 -0
- package/dist/agent/backends/anthropic.js +169 -28
- package/dist/agent/backends/claude_cli.js +18 -8
- package/dist/agent/backends/codex_app_server.d.ts +1 -0
- package/dist/agent/backends/codex_app_server.js +55 -3
- package/dist/agent/backends/codex_auth.d.ts +1 -0
- package/dist/agent/backends/codex_auth.js +67 -13
- package/dist/agent/backends/codex_buffered_recovery.d.ts +27 -0
- package/dist/agent/backends/codex_buffered_recovery.js +293 -0
- package/dist/agent/backends/gemini.js +3 -1
- package/dist/agent/backends/http_error.d.ts +26 -0
- package/dist/agent/backends/http_error.js +221 -0
- package/dist/agent/backends/index.d.ts +9 -2
- package/dist/agent/backends/index.js +22 -2
- package/dist/agent/backends/openai.d.ts +0 -6
- package/dist/agent/backends/openai.js +5 -2
- package/dist/agent/backends/openai_compatible.d.ts +5 -4
- package/dist/agent/backends/openai_compatible.js +26 -19
- package/dist/agent/backends/openai_responses.d.ts +8 -3
- package/dist/agent/backends/openai_responses.js +77 -33
- package/dist/agent/backends/provider_ids.d.ts +1 -0
- package/dist/agent/backends/provider_ids.js +4 -0
- package/dist/agent/backends/request_metrics.js +12 -2
- package/dist/agent/backends/types.d.ts +109 -6
- package/dist/agent/backends/types.js +5 -1
- package/dist/agent/backends/zrouter_stream.d.ts +6 -1
- package/dist/agent/backends/zrouter_stream.js +160 -42
- package/dist/agent/background_registry.d.ts +2 -0
- package/dist/agent/background_registry.js +1 -0
- package/dist/agent/claim_observation.d.ts +6 -0
- package/dist/agent/claim_observation.js +54 -0
- package/dist/agent/commands/accounts.js +46 -177
- package/dist/agent/commands/attention.d.ts +2 -0
- package/dist/agent/commands/attention.js +67 -0
- package/dist/agent/commands/auth.js +18 -2
- package/dist/agent/commands/board.d.ts +2 -0
- package/dist/agent/commands/board.js +169 -0
- package/dist/agent/commands/clipboard.js +50 -1
- package/dist/agent/commands/compact.js +21 -13
- package/dist/agent/commands/config.js +4 -1
- package/dist/agent/commands/context.js +6 -1
- package/dist/agent/commands/desktop.d.ts +2 -0
- package/dist/agent/commands/desktop.js +29 -0
- package/dist/agent/commands/effort.js +14 -6
- package/dist/agent/commands/environment.d.ts +3 -0
- package/dist/agent/commands/environment.js +109 -0
- package/dist/agent/commands/evidence.d.ts +2 -0
- package/dist/agent/commands/evidence.js +9 -0
- package/dist/agent/commands/fork.js +4 -3
- package/dist/agent/commands/goal.d.ts +33 -1
- package/dist/agent/commands/goal.js +174 -14
- package/dist/agent/commands/goal_exit.d.ts +36 -0
- package/dist/agent/commands/goal_exit.js +120 -0
- package/dist/agent/commands/help.js +3 -1
- package/dist/agent/commands/history.js +3 -1
- package/dist/agent/commands/inbox.d.ts +2 -0
- package/dist/agent/commands/inbox.js +148 -0
- package/dist/agent/commands/index.js +16 -1
- package/dist/agent/commands/input_mode.js +8 -6
- package/dist/agent/commands/job.d.ts +2 -0
- package/dist/agent/commands/job.js +93 -0
- package/dist/agent/commands/keybindings.js +4 -1
- package/dist/agent/commands/label.d.ts +2 -0
- package/dist/agent/commands/label.js +77 -0
- package/dist/agent/commands/layout.d.ts +2 -0
- package/dist/agent/commands/layout.js +20 -0
- package/dist/agent/commands/mirror.js +16 -1
- package/dist/agent/commands/model.js +12 -2
- package/dist/agent/commands/permissions.d.ts +3 -1
- package/dist/agent/commands/permissions.js +79 -89
- package/dist/agent/commands/plan.js +5 -15
- package/dist/agent/commands/profile.js +21 -2
- package/dist/agent/commands/provider_selection.js +20 -1
- package/dist/agent/commands/rename.js +1 -1
- package/dist/agent/commands/resources.d.ts +2 -0
- package/dist/agent/commands/resources.js +67 -0
- package/dist/agent/commands/rewind.d.ts +3 -0
- package/dist/agent/commands/rewind.js +16 -0
- package/dist/agent/commands/runbooks.d.ts +2 -0
- package/dist/agent/commands/runbooks.js +73 -0
- package/dist/agent/commands/runbooks_lazy.d.ts +2 -0
- package/dist/agent/commands/runbooks_lazy.js +8 -0
- package/dist/agent/commands/sessions.d.ts +1 -1
- package/dist/agent/commands/sessions.js +33 -5
- package/dist/agent/commands/settings.js +185 -39
- package/dist/agent/commands/setup.d.ts +2 -0
- package/dist/agent/commands/setup.js +78 -0
- package/dist/agent/commands/share.d.ts +2 -0
- package/dist/agent/commands/share.js +64 -0
- package/dist/agent/commands/status.js +14 -2
- package/dist/agent/commands/types.d.ts +99 -6
- package/dist/agent/commands/update.js +8 -0
- package/dist/agent/commands/usage.d.ts +2 -0
- package/dist/agent/commands/usage.js +8 -0
- package/dist/agent/factory.d.ts +11 -0
- package/dist/agent/factory.js +113 -88
- package/dist/agent/loop_policy.d.ts +6 -3
- package/dist/agent/loop_policy.js +12 -3
- package/dist/agent/measurement_guidance.d.ts +2 -0
- package/dist/agent/measurement_guidance.js +5 -0
- package/dist/agent/request_encoder.d.ts +8 -1
- package/dist/agent/request_encoder.js +95 -23
- package/dist/agent/runtime/benchmark_comparison.d.ts +30 -0
- package/dist/agent/runtime/benchmark_comparison.js +94 -0
- package/dist/agent/runtime/benchmark_contract.d.ts +48 -0
- package/dist/agent/runtime/benchmark_contract.js +90 -0
- package/dist/agent/runtime/checked_commands.d.ts +32 -0
- package/dist/agent/runtime/checked_commands.js +119 -0
- package/dist/agent/runtime/command_execution_policy.d.ts +26 -0
- package/dist/agent/runtime/command_execution_policy.js +91 -0
- package/dist/agent/runtime/command_jobs.d.ts +36 -18
- package/dist/agent/runtime/command_jobs.js +151 -13
- package/dist/agent/runtime/command_panes.d.ts +59 -0
- package/dist/agent/runtime/command_panes.js +398 -0
- package/dist/agent/runtime/command_wait.d.ts +19 -0
- package/dist/agent/runtime/command_wait.js +167 -0
- package/dist/agent/runtime/cpu_benchmark.d.ts +30 -0
- package/dist/agent/runtime/cpu_benchmark.js +126 -0
- package/dist/agent/runtime/delegated_evidence.d.ts +26 -0
- package/dist/agent/runtime/delegated_evidence.js +80 -0
- package/dist/agent/runtime/evidence.d.ts +127 -0
- package/dist/agent/runtime/evidence.js +468 -0
- package/dist/agent/runtime/execution_context.d.ts +7 -0
- package/dist/agent/runtime/execution_context.js +39 -3
- package/dist/agent/runtime/json_verifier_recipe.d.ts +8 -0
- package/dist/agent/runtime/json_verifier_recipe.js +66 -0
- package/dist/agent/runtime/measurements.d.ts +110 -0
- package/dist/agent/runtime/measurements.js +317 -0
- package/dist/agent/runtime/policy.d.ts +4 -0
- package/dist/agent/runtime/policy.js +31 -20
- package/dist/agent/runtime/provider_native_evidence.d.ts +20 -0
- package/dist/agent/runtime/provider_native_evidence.js +86 -0
- package/dist/agent/runtime/recovery.d.ts +2 -0
- package/dist/agent/runtime/recovery.js +45 -2
- package/dist/agent/runtime/remote_broker_transport.d.ts +10 -0
- package/dist/agent/runtime/remote_broker_transport.js +84 -0
- package/dist/agent/runtime/remote_deletion_receipts.d.ts +3 -0
- package/dist/agent/runtime/remote_deletion_receipts.js +43 -0
- package/dist/agent/runtime/remote_journal_replay.d.ts +13 -0
- package/dist/agent/runtime/remote_journal_replay.js +233 -0
- package/dist/agent/runtime/remote_mutation_journal.d.ts +25 -0
- package/dist/agent/runtime/remote_mutation_journal.js +208 -0
- package/dist/agent/runtime/remote_resource_endpoint.d.ts +7 -0
- package/dist/agent/runtime/remote_resource_endpoint.js +6 -0
- package/dist/agent/runtime/remote_resource_errors.d.ts +5 -0
- package/dist/agent/runtime/remote_resource_errors.js +9 -0
- package/dist/agent/runtime/remote_resource_script.d.ts +2 -0
- package/dist/agent/runtime/remote_resource_script.js +1009 -0
- package/dist/agent/runtime/remote_resources.d.ts +29 -0
- package/dist/agent/runtime/remote_resources.js +160 -0
- package/dist/agent/runtime/source_verification.d.ts +12 -0
- package/dist/agent/runtime/source_verification.js +210 -0
- package/dist/agent/runtime/source_verification_contract.d.ts +75 -0
- package/dist/agent/runtime/source_verification_contract.js +203 -0
- package/dist/agent/runtime/tool_checkpoints.d.ts +11 -0
- package/dist/agent/runtime/tool_checkpoints.js +26 -0
- package/dist/agent/runtime/tool_effects.d.ts +5 -0
- package/dist/agent/runtime/tool_progress.d.ts +12 -0
- package/dist/agent/runtime/tool_progress.js +76 -0
- package/dist/agent/runtime/tool_runtime.d.ts +16 -1
- package/dist/agent/runtime/tool_runtime.js +204 -10
- package/dist/agent/runtime/tracing.d.ts +1 -1
- package/dist/agent/runtime/verifier_capture.d.ts +9 -0
- package/dist/agent/runtime/verifier_capture.js +100 -0
- package/dist/agent/runtime/verifier_contract.d.ts +24 -0
- package/dist/agent/runtime/verifier_contract.js +41 -0
- package/dist/agent/subagent.d.ts +20 -10
- package/dist/agent/subagent.js +266 -29
- package/dist/agent/tool_response_directory.d.ts +24 -0
- package/dist/agent/tool_response_directory.js +61 -0
- package/dist/agent/tool_response_source.d.ts +8 -0
- package/dist/agent/tool_response_source.js +49 -0
- package/dist/agent/tool_response_store.d.ts +75 -4
- package/dist/agent/tool_response_store.js +583 -64
- package/dist/agent/tools/ask_user.d.ts +3 -1
- package/dist/agent/tools/ask_user.js +30 -11
- package/dist/agent/tools/background.js +1 -0
- package/dist/agent/tools/benchmark_cpu.d.ts +2 -0
- package/dist/agent/tools/benchmark_cpu.js +102 -0
- package/dist/agent/tools/compare_benchmarks.d.ts +2 -0
- package/dist/agent/tools/compare_benchmarks.js +53 -0
- package/dist/agent/tools/deferred.js +1 -0
- package/dist/agent/tools/delegate.js +18 -2
- package/dist/agent/tools/helper_requirements.d.ts +7 -0
- package/dist/agent/tools/helper_requirements.js +19 -0
- package/dist/agent/tools/index.d.ts +1 -0
- package/dist/agent/tools/index.js +3 -0
- package/dist/agent/tools/measurement_report.d.ts +22 -0
- package/dist/agent/tools/measurement_report.js +190 -0
- package/dist/agent/tools/pane.d.ts +0 -7
- package/dist/agent/tools/pane.js +5 -4
- package/dist/agent/tools/project_context.d.ts +3 -0
- package/dist/agent/tools/project_context.js +45 -0
- package/dist/agent/tools/recall_tool_response.d.ts +3 -5
- package/dist/agent/tools/recall_tool_response.js +19 -29
- package/dist/agent/tools/registry.d.ts +1 -0
- package/dist/agent/tools/registry.js +23 -0
- package/dist/agent/tools/related_sessions.d.ts +2 -0
- package/dist/agent/tools/related_sessions.js +156 -0
- package/dist/agent/tools/routine.js +13 -3
- package/dist/agent/tools/session_evidence.d.ts +3 -0
- package/dist/agent/tools/session_evidence.js +72 -0
- package/dist/agent/tools/shell.d.ts +4 -3
- package/dist/agent/tools/shell.js +174 -47
- package/dist/agent/tools/tool_search.js +3 -2
- package/dist/agent/tools/types.d.ts +28 -0
- package/dist/agent/tools/verify_artifact.d.ts +2 -0
- package/dist/agent/tools/verify_artifact.js +60 -0
- package/dist/agent/tools/workflow.js +1 -0
- package/dist/app/activity.d.ts +6 -1
- package/dist/app/activity.js +81 -12
- package/dist/app/agent_runner.d.ts +6 -1
- package/dist/app/agent_runner.js +34 -19
- package/dist/app/command_context.js +106 -10
- package/dist/app/controller.d.ts +15 -4
- package/dist/app/controller.js +29 -3
- package/dist/app/escape_stop.d.ts +11 -0
- package/dist/app/escape_stop.js +15 -0
- package/dist/app/liveness.d.ts +6 -4
- package/dist/app/liveness.js +13 -3
- package/dist/app/model_catalog_cache.d.ts +5 -0
- package/dist/app/model_catalog_cache.js +138 -0
- package/dist/app/ordered_transcript.js +43 -4
- package/dist/app/prompt_turn.d.ts +14 -0
- package/dist/app/prompt_turn.js +53 -0
- package/dist/app/recap.d.ts +3 -0
- package/dist/app/recap.js +9 -3
- package/dist/app/restart.d.ts +3 -2
- package/dist/app/restart.js +22 -7
- package/dist/app/rewind.d.ts +3 -3
- package/dist/app/rewind.js +3 -3
- package/dist/app/selection_completion.d.ts +17 -2
- package/dist/app/selection_completion.js +84 -9
- package/dist/app/session_manager.d.ts +38 -6
- package/dist/app/session_manager.js +263 -60
- package/dist/app/session_messages.d.ts +3 -0
- package/dist/app/session_messages.js +60 -0
- package/dist/app/task_scope_router.d.ts +20 -0
- package/dist/app/task_scope_router.js +173 -0
- package/dist/app/transcript_window.d.ts +2 -0
- package/dist/app/transcript_window.js +4 -0
- package/dist/app/workspace_admission_details.d.ts +3 -0
- package/dist/app/workspace_admission_details.js +26 -0
- package/dist/app/workspace_layout_controller.d.ts +11 -0
- package/dist/app/workspace_layout_controller.js +88 -0
- package/dist/app/workspace_monitor.d.ts +1 -0
- package/dist/app/workspace_monitor.js +216 -0
- package/dist/app/workspace_presets.d.ts +29 -0
- package/dist/app/workspace_presets.js +192 -0
- package/dist/attention/queue.d.ts +37 -0
- package/dist/attention/queue.js +183 -0
- package/dist/bench/rf_shared.d.ts +2 -0
- package/dist/board/binding.d.ts +42 -0
- package/dist/board/binding.js +38 -0
- package/dist/board/client.d.ts +42 -0
- package/dist/board/client.js +60 -0
- package/dist/cli.js +1 -1
- package/dist/cli_args.js +19 -17
- package/dist/cli_main.js +110 -13
- package/dist/cli_ratatui.d.ts +183 -16
- package/dist/cli_ratatui.js +3989 -1255
- package/dist/components/ActivityLine.d.ts +3 -2
- package/dist/components/ActivityLine.js +2 -2
- package/dist/components/InputArea.d.ts +19 -1
- package/dist/components/InputArea.js +264 -190
- package/dist/components/MessageList.d.ts +2 -0
- package/dist/components/MessageList.js +3 -1
- package/dist/components/SingleMessage.d.ts +2 -0
- package/dist/components/SingleMessage.js +4 -2
- package/dist/components/StatusBar.d.ts +4 -2
- package/dist/components/StatusBar.js +3 -1
- package/dist/conductor_branch.js +3 -0
- package/dist/config/api_retry.d.ts +6 -1
- package/dist/config/api_retry.js +19 -1
- package/dist/config/defaults.d.ts +2 -1
- package/dist/config/defaults.js +9 -2
- package/dist/config/message_display.d.ts +1 -0
- package/dist/config/message_display.js +17 -2
- package/dist/config/permissions.d.ts +21 -0
- package/dist/config/permissions.js +89 -0
- package/dist/config/resolved.d.ts +2 -2
- package/dist/config/types.d.ts +32 -5
- package/dist/config.d.ts +38 -4
- package/dist/config.js +290 -42
- package/dist/context/project_intelligence.d.ts +44 -0
- package/dist/context/project_intelligence.js +246 -0
- package/dist/desktop/cli_launch.d.ts +8 -0
- package/dist/desktop/cli_launch.js +6 -0
- package/dist/desktop/cli_shim.d.ts +8 -0
- package/dist/desktop/cli_shim.js +120 -0
- package/dist/desktop/runtime.d.ts +16 -0
- package/dist/desktop/runtime.js +161 -0
- package/dist/desktop/worker_launch.d.ts +7 -0
- package/dist/desktop/worker_launch.js +7 -0
- package/dist/diagnostics/cargo_mutants_selection.d.ts +27 -0
- package/dist/diagnostics/cargo_mutants_selection.js +48 -0
- package/dist/diagnostics/native_terminal_benchmark.d.ts +58 -0
- package/dist/diagnostics/native_terminal_benchmark.js +1247 -0
- package/dist/diagnostics/session_evidence_package.d.ts +40 -0
- package/dist/diagnostics/session_evidence_package.js +234 -0
- package/dist/diagnostics/session_export.d.ts +93 -0
- package/dist/diagnostics/session_export.js +634 -0
- package/dist/emulation/tool_adapters.js +48 -18
- package/dist/emulation/ztc_addendum.d.ts +1 -0
- package/dist/emulation/ztc_addendum.js +13 -12
- package/dist/environment/service_runner.d.ts +1 -0
- package/dist/environment/service_runner.js +68 -0
- package/dist/environment/store.d.ts +120 -0
- package/dist/environment/store.js +387 -0
- package/dist/events.d.ts +40 -1
- package/dist/events.js +10 -17
- package/dist/goal_contract.d.ts +25 -0
- package/dist/goal_contract.js +44 -0
- package/dist/headless.d.ts +31 -0
- package/dist/headless.js +578 -70
- package/dist/headless_cli.js +8 -0
- package/dist/headless_cli_args.d.ts +1 -0
- package/dist/headless_cli_args.js +1 -0
- package/dist/intake/cli.d.ts +1 -0
- package/dist/intake/cli.js +33 -0
- package/dist/intake/format.d.ts +2 -0
- package/dist/intake/format.js +14 -0
- package/dist/intake/notifications.d.ts +16 -0
- package/dist/intake/notifications.js +132 -0
- package/dist/intake/store.d.ts +56 -0
- package/dist/intake/store.js +238 -0
- package/dist/library.d.ts +8 -0
- package/dist/library.js +19 -10
- package/dist/mcp/client.d.ts +1 -0
- package/dist/mcp/client.js +20 -0
- package/dist/modes/builtin/plan.yaml +2 -2
- package/dist/modes/handlers.js +1 -1
- package/dist/onboarding/first_run.d.ts +3 -0
- package/dist/onboarding/first_run.js +33 -25
- package/dist/onboarding/setup.d.ts +32 -0
- package/dist/onboarding/setup.js +133 -0
- package/dist/onboarding/setup_agent_health.d.ts +17 -0
- package/dist/onboarding/setup_agent_health.js +79 -0
- package/dist/onboarding/setup_environment.d.ts +66 -0
- package/dist/onboarding/setup_environment.js +219 -0
- package/dist/onboarding/setup_workspace.d.ts +17 -0
- package/dist/onboarding/setup_workspace.js +129 -0
- package/dist/optimize/adapters/index.d.ts +4 -1
- package/dist/optimize/adapters/index.js +7 -0
- package/dist/optimize/adapters/kernelbench.js +14 -5
- package/dist/optimize/adapters/popcorn.js +3 -2
- package/dist/optimize/adapters/profile_benchmark.js +13 -7
- package/dist/optimize/adapters/rfbench.js +25 -14
- package/dist/optimize/adapters/types.d.ts +3 -0
- package/dist/optimize/adapters/zergpod.js +5 -2
- package/dist/optimize/baseline.js +10 -21
- package/dist/optimize/branch.js +9 -12
- package/dist/optimize/combine.js +2 -3
- package/dist/optimize/executor.js +34 -48
- package/dist/optimize/measurements.d.ts +55 -0
- package/dist/optimize/measurements.js +173 -0
- package/dist/optimize/results.d.ts +1 -0
- package/dist/optimize/results.js +11 -33
- package/dist/optimize/tge.js +2 -1
- package/dist/panes/initial_input.d.ts +19 -0
- package/dist/panes/initial_input.js +206 -0
- package/dist/panes/labels.d.ts +16 -0
- package/dist/panes/labels.js +42 -0
- package/dist/panes/model_catalog.d.ts +3 -3
- package/dist/panes/model_catalog.js +28 -4
- package/dist/panes/pane_registry.js +6 -2
- package/dist/panes/types.d.ts +3 -0
- package/dist/panes/ztc_launch.js +19 -0
- package/dist/plugin/optimization.d.ts +2 -0
- package/dist/plugin/optimization.js +1 -0
- package/dist/plugin/runtime.d.ts +2 -0
- package/dist/plugin/simulation.d.ts +6 -1
- package/dist/plugin/simulation_contract.d.ts +836 -1
- package/dist/plugin/simulation_documents.d.ts +22 -0
- package/dist/plugin/simulation_documents.js +1 -0
- package/dist/plugin/simulation_interventions.d.ts +103 -0
- package/dist/plugin/simulation_interventions.js +1 -0
- package/dist/plugin/simulation_living.d.ts +1 -0
- package/dist/plugin/simulation_living.js +3 -0
- package/dist/plugin/simulation_meetings.d.ts +121 -0
- package/dist/plugin/simulation_meetings.js +1 -0
- package/dist/plugin/simulation_story_review.d.ts +111 -0
- package/dist/plugin/simulation_story_review.js +1 -0
- package/dist/plugin/simulation_storyteller.d.ts +91 -0
- package/dist/plugin/simulation_storyteller.js +1 -0
- package/dist/plugin/simulation_structured.d.ts +2 -0
- package/dist/plugin/simulation_structured.js +2 -0
- package/dist/plugin/simulation_toolkit.d.ts +21 -0
- package/dist/plugin/simulation_toolkit.js +24 -0
- package/dist/prompts/tool_delegate_task.j2 +1 -1
- package/dist/remote-broker/Dockerfile +10 -0
- package/dist/remote-broker/archive.py +1227 -0
- package/dist/remote-broker/cutover.py +761 -0
- package/dist/remote-broker/requirements.txt +5 -0
- package/dist/remote-broker/resources.json +6 -0
- package/dist/remote-broker/resources.write-staging.example.json +18 -0
- package/dist/remote-broker/retained.py +686 -0
- package/dist/remote-broker/retained_cleanup.py +550 -0
- package/dist/remote-broker/server.py +648 -0
- package/dist/remote-broker/worker.py +1007 -0
- package/dist/router_auth.d.ts +31 -0
- package/dist/router_auth.js +155 -9
- package/dist/runbooks/catalog.d.ts +3 -0
- package/dist/runbooks/catalog.js +26 -0
- package/dist/runbooks/library.d.ts +11 -0
- package/dist/runbooks/library.js +95 -0
- package/dist/runbooks/package.d.ts +43 -0
- package/dist/runbooks/package.js +102 -0
- package/dist/session.d.ts +65 -13
- package/dist/session.js +548 -43
- package/dist/session_handoff.d.ts +25 -0
- package/dist/session_handoff.js +322 -0
- package/dist/session_handoff_contract.d.ts +89 -0
- package/dist/session_handoff_contract.js +179 -0
- package/dist/session_media.d.ts +41 -0
- package/dist/session_media.js +357 -0
- package/dist/session_ownership.d.ts +16 -0
- package/dist/session_ownership.js +221 -0
- package/dist/session_public.d.ts +10 -2
- package/dist/session_public.js +6 -1
- package/dist/simulation/dynamic_history.d.ts +42 -0
- package/dist/simulation/dynamic_history.js +173 -0
- package/dist/simulation/engines/swarm/pane_manager.js +7 -6
- package/dist/simulation/inference.d.ts +3 -1
- package/dist/simulation/inference.js +19 -1
- package/dist/simulation/intervention_generation.d.ts +27 -0
- package/dist/simulation/intervention_generation.js +246 -0
- package/dist/simulation/intervention_material.d.ts +4 -0
- package/dist/simulation/intervention_material.js +81 -0
- package/dist/simulation/living/context.d.ts +3130 -0
- package/dist/simulation/living/context.js +944 -0
- package/dist/simulation/living/definer.d.ts +12 -0
- package/dist/simulation/living/definer.js +223 -0
- package/dist/simulation/living/definition.d.ts +257 -0
- package/dist/simulation/living/definition.js +341 -0
- package/dist/simulation/living/frame.d.ts +259 -0
- package/dist/simulation/living/frame.js +223 -0
- package/dist/simulation/living/identifiers.d.ts +2 -0
- package/dist/simulation/living/identifiers.js +12 -0
- package/dist/simulation/living/index.d.ts +15 -0
- package/dist/simulation/living/index.js +15 -0
- package/dist/simulation/living/inference.d.ts +219 -0
- package/dist/simulation/living/inference.js +576 -0
- package/dist/simulation/living/mechanics.d.ts +54 -0
- package/dist/simulation/living/mechanics.js +588 -0
- package/dist/simulation/living/model-input.d.ts +25 -0
- package/dist/simulation/living/model-input.js +215 -0
- package/dist/simulation/living/open-world.d.ts +257 -0
- package/dist/simulation/living/open-world.js +102 -0
- package/dist/simulation/living/projection.d.ts +7 -0
- package/dist/simulation/living/projection.js +31 -0
- package/dist/simulation/living/recall.d.ts +17 -0
- package/dist/simulation/living/recall.js +25 -0
- package/dist/simulation/living/reduce.d.ts +15 -0
- package/dist/simulation/living/reduce.js +1853 -0
- package/dist/simulation/living/replay.d.ts +6 -0
- package/dist/simulation/living/replay.js +22 -0
- package/dist/simulation/living/runtime.d.ts +140 -0
- package/dist/simulation/living/runtime.js +3020 -0
- package/dist/simulation/living/shared-kinds.d.ts +24 -0
- package/dist/simulation/living/shared-kinds.js +96 -0
- package/dist/simulation/living/types.d.ts +2785 -0
- package/dist/simulation/living/types.js +669 -0
- package/dist/simulation/llm.d.ts +3 -1
- package/dist/simulation/llm.js +90 -30
- package/dist/simulation/memory/embedding.d.ts +1 -1
- package/dist/simulation/memory/embedding.js +9 -3
- package/dist/simulation/memory/filesystem.d.ts +2 -0
- package/dist/simulation/memory/filesystem.js +5 -1
- package/dist/simulation/memory/filesystem_file_tree.d.ts +4 -1
- package/dist/simulation/memory/filesystem_file_tree.js +41 -10
- package/dist/simulation/memory/filesystem_invariants.js +1 -2
- package/dist/simulation/memory/filesystem_types.d.ts +1 -0
- package/dist/simulation/memory/memory.d.ts +2 -0
- package/dist/simulation/memory/memory.js +5 -1
- package/dist/simulation/orchestration/intent.js +0 -2
- package/dist/simulation/orchestration/lifecycle.js +0 -2
- package/dist/simulation/plugin_host.d.ts +25 -1
- package/dist/simulation/plugin_host.js +334 -17
- package/dist/simulation/session_documents.d.ts +20 -0
- package/dist/simulation/session_documents.js +62 -0
- package/dist/simulation/session_input_validation.d.ts +43 -0
- package/dist/simulation/session_input_validation.js +364 -0
- package/dist/simulation/session_inputs.d.ts +32 -0
- package/dist/simulation/session_inputs.js +154 -0
- package/dist/simulation/telemetry.d.ts +11 -14
- package/dist/simulation/telemetry.js +177 -54
- package/dist/simulation/toolkit/bounded_turns.d.ts +13 -0
- package/dist/simulation/toolkit/bounded_turns.js +80 -0
- package/dist/simulation/toolkit/dynamic_agents.d.ts +25 -0
- package/dist/simulation/toolkit/dynamic_agents.js +218 -0
- package/dist/simulation/toolkit/dynamic_digest.d.ts +1 -0
- package/dist/simulation/toolkit/dynamic_digest.js +8 -0
- package/dist/simulation/toolkit/dynamic_documents.d.ts +7 -0
- package/dist/simulation/toolkit/dynamic_documents.js +71 -0
- package/dist/simulation/toolkit/dynamic_driver.d.ts +8 -0
- package/dist/simulation/toolkit/dynamic_driver.js +269 -0
- package/dist/simulation/toolkit/dynamic_game.d.ts +53 -0
- package/dist/simulation/toolkit/dynamic_game.js +805 -0
- package/dist/simulation/toolkit/dynamic_knowledge.d.ts +15 -0
- package/dist/simulation/toolkit/dynamic_knowledge.js +91 -0
- package/dist/simulation/toolkit/dynamic_mechanics.d.ts +22 -0
- package/dist/simulation/toolkit/dynamic_mechanics.js +910 -0
- package/dist/simulation/toolkit/dynamic_meetings.d.ts +239 -0
- package/dist/simulation/toolkit/dynamic_meetings.js +350 -0
- package/dist/simulation/toolkit/dynamic_progress.d.ts +131 -0
- package/dist/simulation/toolkit/dynamic_progress.js +214 -0
- package/dist/simulation/toolkit/dynamic_projection.d.ts +9 -0
- package/dist/simulation/toolkit/dynamic_projection.js +219 -0
- package/dist/simulation/toolkit/dynamic_runtime.d.ts +412 -0
- package/dist/simulation/toolkit/dynamic_runtime.js +6536 -0
- package/dist/simulation/toolkit/dynamic_state.d.ts +111 -0
- package/dist/simulation/toolkit/dynamic_state.js +1967 -0
- package/dist/simulation/toolkit/dynamic_story_review.d.ts +508 -0
- package/dist/simulation/toolkit/dynamic_story_review.js +333 -0
- package/dist/simulation/toolkit/dynamic_storyteller.d.ts +77 -0
- package/dist/simulation/toolkit/dynamic_storyteller.js +114 -0
- package/dist/simulation/toolkit/dynamic_storyteller_runtime.d.ts +26 -0
- package/dist/simulation/toolkit/dynamic_storyteller_runtime.js +126 -0
- package/dist/simulation/toolkit/dynamic_world.d.ts +22 -0
- package/dist/simulation/toolkit/dynamic_world.js +377 -0
- package/dist/simulation/toolkit/structured_output.d.ts +5 -0
- package/dist/simulation/toolkit/structured_output.js +321 -0
- package/dist/simulation/types/world.d.ts +2 -0
- package/dist/simulation/world/artifact_store.js +1 -1
- package/dist/simulation/world/memory_manager.d.ts +5 -0
- package/dist/simulation/world/memory_manager.js +17 -2
- package/dist/simulation/world/state.d.ts +3 -2
- package/dist/simulation/world/state.js +15 -5
- package/dist/skills/bundled/measurement-validation/SKILL.md +144 -0
- package/dist/terminal_attach.d.ts +1 -0
- package/dist/terminal_attach.js +113 -0
- package/dist/tool_progress.d.ts +3 -0
- package/dist/tool_progress.js +27 -0
- package/dist/types.d.ts +145 -10
- package/dist/types.js +3 -0
- package/dist/ui/core/input.d.ts +3 -0
- package/dist/ui/core/input_segments.d.ts +18 -1
- package/dist/ui/core/input_segments.js +131 -47
- package/dist/ui/core/input_state.d.ts +12 -0
- package/dist/ui/core/layout_yoga.d.ts +1 -1
- package/dist/ui/core/turn_metadata.d.ts +5 -0
- package/dist/ui/core/turn_metadata.js +22 -0
- package/dist/ui/ink/render.d.ts +2 -1
- package/dist/ui/ink/render.js +6 -5
- package/dist/ui/ratatui/headless_pane_manager.d.ts +14 -1
- package/dist/ui/ratatui/headless_pane_manager.js +293 -104
- package/dist/ui/ratatui/index.d.ts +1 -0
- package/dist/ui/ratatui/input.d.ts +4 -0
- package/dist/ui/ratatui/input.js +15 -0
- package/dist/ui/ratatui/ipc_path.d.ts +2 -0
- package/dist/ui/ratatui/ipc_path.js +29 -1
- package/dist/ui/ratatui/mux_persistence.d.ts +3 -1
- package/dist/ui/ratatui/mux_persistence.js +139 -1
- package/dist/ui/ratatui/pane_relay.d.ts +1 -1
- package/dist/ui/ratatui/pane_relay.js +55 -27
- package/dist/ui/ratatui/process.d.ts +17 -1
- package/dist/ui/ratatui/process.js +80 -7
- package/dist/ui/ratatui/protocol.d.ts +236 -1
- package/dist/ui/ratatui/protocol.js +90 -0
- package/dist/ui/scrollback.d.ts +2 -1
- package/dist/ui/scrollback.js +9 -4
- package/dist/ui/stream_typewriter.d.ts +8 -3
- package/dist/ui/stream_typewriter.js +30 -1
- package/dist/ui/streaming_scrollback.d.ts +1 -0
- package/dist/ui/streaming_scrollback.js +1 -1
- package/dist/ui/transcript_render_cache.js +10 -0
- package/dist/ui/views/activity_line.d.ts +5 -3
- package/dist/ui/views/activity_line.js +65 -7
- package/dist/ui/views/app.d.ts +6 -3
- package/dist/ui/views/app.js +17 -6
- package/dist/ui/views/completion_menu.js +10 -5
- package/dist/ui/views/input_area.d.ts +7 -3
- package/dist/ui/views/input_area.js +15 -9
- package/dist/ui/views/message_list.d.ts +4 -1
- package/dist/ui/views/message_list.js +89 -19
- package/dist/ui/views/queue_panel.d.ts +3 -1
- package/dist/ui/views/queue_panel.js +9 -2
- package/dist/ui/views/status_bar.d.ts +4 -2
- package/dist/ui/views/status_bar.js +28 -13
- package/dist/ui/views/theme.d.ts +2 -0
- package/dist/ui/views/theme.js +2 -0
- package/dist/ui/widgets/color_panel.js +18 -0
- package/dist/ui/widgets/form.js +8 -0
- package/dist/ui/widgets/log_view.js +7 -0
- package/dist/ui/widgets/navigator_panel.js +8 -0
- package/dist/ui/widgets/prompt_viewer.js +2 -0
- package/dist/ui/widgets/questionnaire.d.ts +2 -0
- package/dist/ui/widgets/questionnaire.js +79 -16
- package/dist/ui/widgets/resume_panel.d.ts +2 -0
- package/dist/ui/widgets/resume_panel.js +26 -15
- package/dist/ui/widgets/rewind_panel.d.ts +2 -2
- package/dist/ui/widgets/rewind_panel.js +4 -2
- package/dist/ui/widgets/select.js +16 -27
- package/dist/ui/widgets/settings_panel.js +16 -0
- package/dist/ui/widgets/status_line_panel.d.ts +1 -0
- package/dist/ui/widgets/status_line_panel.js +30 -4
- package/dist/ui/widgets/text_input.d.ts +2 -0
- package/dist/ui/widgets/text_input.js +15 -1
- package/dist/ui/widgets/types.d.ts +2 -0
- package/dist/usage_projection.d.ts +24 -0
- package/dist/usage_projection.js +130 -0
- package/dist/usage_snapshot.d.ts +36 -0
- package/dist/usage_snapshot.js +131 -0
- package/dist/utils/assistant_message.d.ts +2 -0
- package/dist/utils/assistant_message.js +5 -0
- package/dist/utils/atomic_write.d.ts +6 -2
- package/dist/utils/atomic_write.js +29 -4
- package/dist/utils/checked_shell.d.ts +25 -0
- package/dist/utils/checked_shell.js +107 -0
- package/dist/utils/compaction.d.ts +4 -1
- package/dist/utils/compaction.js +31 -7
- package/dist/utils/context.d.ts +4 -4
- package/dist/utils/context.js +14 -12
- package/dist/utils/deepseek.d.ts +2 -2
- package/dist/utils/deepseek.js +13 -5
- package/dist/utils/ephemeral.js +4 -0
- package/dist/utils/ephemeral_tools.d.ts +18 -0
- package/dist/utils/ephemeral_tools.js +97 -0
- package/dist/utils/jev_client.d.ts +4 -0
- package/dist/utils/jev_client.js +15 -0
- package/dist/utils/model_cache.d.ts +15 -0
- package/dist/utils/model_cache.js +143 -0
- package/dist/utils/models.d.ts +4 -2
- package/dist/utils/models.js +12 -16
- package/dist/utils/reasoning.d.ts +8 -1
- package/dist/utils/reasoning.js +31 -11
- package/dist/utils/router_catalog.d.ts +49 -0
- package/dist/utils/router_catalog.js +271 -0
- package/dist/utils/sandbox.d.ts +9 -0
- package/dist/utils/sandbox.js +118 -15
- package/dist/utils/shell.d.ts +7 -0
- package/dist/utils/shell.js +98 -34
- package/dist/utils/subprocess_env.d.ts +3 -0
- package/dist/utils/subprocess_env.js +27 -1
- package/dist/web/accounts_mirror.d.ts +3 -1
- package/dist/web/accounts_mirror.js +13 -9
- package/dist/web/child_publication.d.ts +17 -0
- package/dist/web/child_publication.js +45 -0
- package/dist/web/mirror_authenticator.d.ts +31 -11
- package/dist/web/mirror_authenticator.js +67 -17
- package/dist/web/mirror_client.d.ts +31 -4
- package/dist/web/mirror_client.js +189 -23
- package/dist/web/mirror_stream.d.ts +84 -22
- package/dist/web/mirror_stream.js +676 -95
- package/dist/web/observer_query_redaction.d.ts +6 -0
- package/dist/web/observer_query_redaction.js +58 -0
- package/dist/web/pane_registry.d.ts +2 -0
- package/dist/web/pane_registry.js +86 -22
- package/dist/web/tool_image.d.ts +6 -0
- package/dist/web/tool_image.js +17 -0
- package/dist/web/view_plugin.d.ts +14 -0
- package/dist/web/view_plugin.js +23 -1
- package/dist/worker/executor.js +31 -12
- package/dist/worker/index.d.ts +1 -0
- package/dist/worker/toolsets.js +3 -1
- package/dist/worker/types.d.ts +12 -1
- package/dist/workflows/registry.js +1 -0
- package/dist/workflows/registry_handle.d.ts +2 -0
- package/dist/workflows/runtime.d.ts +2 -0
- package/dist/workflows/runtime.js +1 -0
- package/dist/workspace_agent.d.ts +7 -0
- package/dist/workspace_agent.js +36 -0
- package/dist/workspace_collision.d.ts +76 -0
- package/dist/workspace_collision.js +688 -0
- package/dist/workspace_layout.d.ts +18 -0
- package/dist/workspace_layout.js +121 -0
- package/dist/workspace_runtime.d.ts +44 -0
- package/dist/workspace_runtime.js +82 -0
- package/dist/workspace_session_context.d.ts +62 -0
- package/dist/workspace_session_context.js +160 -0
- package/dist/zlm-embed.mjs +32518 -9615
- package/dist/zlm.js +1 -0
- package/node_modules/@zerg/model-contracts/LICENSE.md +375 -0
- package/node_modules/@zerg/model-contracts/README.md +90 -0
- package/node_modules/@zerg/model-contracts/index.d.ts +20 -0
- package/node_modules/@zerg/model-contracts/index.js +223 -0
- package/node_modules/@zerg/model-contracts/package.json +8 -0
- package/node_modules/glob/node_modules/brace-expansion/index.js +78 -22
- package/node_modules/glob/node_modules/brace-expansion/package.json +1 -1
- package/node_modules/readdir-glob/node_modules/brace-expansion/index.js +78 -22
- package/node_modules/readdir-glob/node_modules/brace-expansion/package.json +1 -1
- package/package.json +45 -24
- package/packages/model-contracts/LICENSE.md +375 -0
- package/packages/model-contracts/README.md +90 -0
- package/packages/model-contracts/index.d.ts +20 -0
- package/packages/model-contracts/index.js +223 -0
- package/packages/model-contracts/package.json +8 -0
- package/remote-broker/Dockerfile +10 -0
- package/remote-broker/README.md +308 -0
- package/remote-broker/archive.py +1227 -0
- package/remote-broker/cutover.py +761 -0
- package/remote-broker/requirements.txt +5 -0
- package/remote-broker/resources.example.json +6 -0
- package/remote-broker/resources.write-staging.example.json +18 -0
- package/remote-broker/retained.py +686 -0
- package/remote-broker/retained_cleanup.py +550 -0
- package/remote-broker/server.py +648 -0
- package/scripts/build-remote-broker.mjs +13 -0
- package/scripts/ensure-local-packages.mjs +6 -0
- package/scripts/node-pty-helpers.mjs +103 -0
|
@@ -0,0 +1,3020 @@
|
|
|
1
|
+
import { assertLivingModelCall, withContextLookup, MODEL_VIEW_NOTE, contextSelectionInput } from './model-input.js';
|
|
2
|
+
import { BOUNDED_IDENTIFIERS_ENGINE, generatedId } from './identifiers.js';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { fitSpeechInput, speechSetting, modelContextPeople, CHARACTER_MESSAGE_SYSTEM, CHARACTER_PLAN_SYSTEM, CHARACTER_REACTION_SYSTEM, GAME_MASTER_SYSTEM, NARRATOR_EPILOGUE_SYSTEM, SPEECH_IMPACT_SYSTEM, characterMessageReplyInput, characterPlanInput, characterReactionInput, clockLabel, epilogueInput, gameMasterAttemptInput, gameMasterWorldInput, earlierTimes, personLabel, } from './context.js';
|
|
7
|
+
import { createLivingState } from './definition.js';
|
|
8
|
+
import { SIGNAL_NOTICES, SIGNAL_SENSES, LIVING_INFERENCE_LIMITS, epilogueResponseSchema, messageReplyResponseSchema, scopedPlanResponseSchema, scopedAttemptResponseSchema, scopedReactionResponseSchema, scopedSpeechImpactResponseSchema, } from './inference.js';
|
|
9
|
+
import { blockedByGate, gatesAgainst, heldHere, importanceFloorForEffects, normViolationsFor, signalNotice, predictionReady } from './mechanics.js';
|
|
10
|
+
import { kindRequestSchema } from './open-world.js';
|
|
11
|
+
import { observedSummary } from './projection.js';
|
|
12
|
+
import { applyLivingEvent, arrivalFor, clockTargetAt, countNote, countResult, nearbyFor, peopleAt, travelPath, unansweredSince, votingShut, witnessesFor, TRAVEL_MINUTES_PER_HOP } from './reduce.js';
|
|
13
|
+
import { AVERTED_ENGINE, isNight, REMINDER_ENGINE, REMINDER_MINUTES, LONG_DAY_MINUTES, LivingWorldError, MAX_CLOCK_RATE, MAX_LINE, MAX_TEXT, MAX_ROLE, FULL_SCHEDULE_INTENT_ENGINE, MAX_SPOKEN, PROMISE_IMPORTANCE, PROMISE_LEAD_MINUTES, STALL_MINUTES, THING_MEMORY_ENGINE, THINGS_SEEN_ENGINE, WALK_OFF_ENGINE, effectSchema, endingSchema, } from './types.js';
|
|
14
|
+
export const MAX_DUE_ITEMS_PER_STEP = 2;
|
|
15
|
+
export const MAX_DUE_ERRANDS_PER_STEP = 4;
|
|
16
|
+
export const MAX_REACTORS = 2;
|
|
17
|
+
// Every player command runs within this many model calls. The player's own attempt or
|
|
18
|
+
// words come first; reactions, replies and due plans are skipped once the budget is spent.
|
|
19
|
+
export const MAX_MODEL_CALLS_PER_COMMAND = 6;
|
|
20
|
+
export const MAX_NONPLAYER_MINUTES_PER_COMMAND = 20;
|
|
21
|
+
// From this importance up, an act is a shock: every witness decides what to do about it.
|
|
22
|
+
export const SHOCK_IMPORTANCE = 7;
|
|
23
|
+
const ROUTINE_PLAN_IMPORTANCE = 3;
|
|
24
|
+
export const SHOCK_PLAN_DELAY_MINUTES = 5;
|
|
25
|
+
export const OTHERS_MAX_MINUTES = 10;
|
|
26
|
+
// In a live world an act longer than this only begins at once; its end is ruled when its time has passed.
|
|
27
|
+
export const LONG_ACT_MINUTES = 10;
|
|
28
|
+
// A deadline the Game Master cannot rule happens this long after its time, from its data alone.
|
|
29
|
+
export const DEADLINE_GRACE_MINUTES = 15;
|
|
30
|
+
// Failures that mean others' answers must wait, not that the player's act did not happen.
|
|
31
|
+
const LATER_CODES = new Set(['inference-unavailable', 'inference-invalid', 'run-paused']);
|
|
32
|
+
export const ERRAND_DELAY_MINUTES = 3;
|
|
33
|
+
export const SPEECH_MINUTES = 1;
|
|
34
|
+
export const MESSAGE_MINUTES = 1;
|
|
35
|
+
export const MESSAGE_REPLY_DELAY_MINUTES = 10;
|
|
36
|
+
export const IDLE_WAIT_MINUTES = 60;
|
|
37
|
+
export const OVERDUE_WAIT_MINUTES = 15;
|
|
38
|
+
export const PLAYER_MAX_TEXT_BYTES = 2000;
|
|
39
|
+
// A person whose decision names no next activity keeps at it this long before deciding again.
|
|
40
|
+
export const DEFAULT_ACTIVITY_MINUTES = 30;
|
|
41
|
+
const MIN_SEEN_LENGTH = 3;
|
|
42
|
+
// A ruling that returns to a changed scene is ruled again at most this many times.
|
|
43
|
+
const MAX_STALE_RULINGS = 2;
|
|
44
|
+
const sha256 = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
45
|
+
function characterInputRefs(state, call, actorId, causeSeq = null) {
|
|
46
|
+
if (call.role !== 'character' || actorId === null)
|
|
47
|
+
return [];
|
|
48
|
+
const person = state.people[actorId];
|
|
49
|
+
if (!person)
|
|
50
|
+
return [];
|
|
51
|
+
const memories = new Map(person.memories.map(memory => [memory.id, memory]));
|
|
52
|
+
const refs = [];
|
|
53
|
+
for (const field of ['memories', 'recentlySeen', 'landmarks', 'pressing']) {
|
|
54
|
+
const items = call.input[field];
|
|
55
|
+
if (!Array.isArray(items))
|
|
56
|
+
continue;
|
|
57
|
+
items.forEach((value, index) => {
|
|
58
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
59
|
+
return;
|
|
60
|
+
const id = value.id;
|
|
61
|
+
if (typeof id !== 'string')
|
|
62
|
+
return;
|
|
63
|
+
const memory = memories.get(id);
|
|
64
|
+
if (!memory)
|
|
65
|
+
return;
|
|
66
|
+
refs.push({ path: `/${field}/${index}`, inputSha256: sha256(value), kind: field === 'pressing' ? 'pressing' : 'memory', id,
|
|
67
|
+
evidenceEventSeq: memory.evidence?.eventSeq ?? memory.eventSeq });
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const summaries = call.input.summaries;
|
|
71
|
+
if (Array.isArray(summaries))
|
|
72
|
+
summaries.forEach((value, index) => {
|
|
73
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
74
|
+
return;
|
|
75
|
+
const id = value.id;
|
|
76
|
+
if (typeof id !== 'string')
|
|
77
|
+
return;
|
|
78
|
+
const summary = person.summaries?.find(item => item.id === id && item.personId === actorId);
|
|
79
|
+
if (!summary)
|
|
80
|
+
return;
|
|
81
|
+
refs.push({ path: `/summaries/${index}`, inputSha256: sha256(value), kind: 'summary', id,
|
|
82
|
+
evidenceEventSeq: Math.max(...summary.sourceEventSeqs) });
|
|
83
|
+
});
|
|
84
|
+
const requests = call.input.requests;
|
|
85
|
+
if (Array.isArray(requests))
|
|
86
|
+
requests.forEach((value, index) => {
|
|
87
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
88
|
+
return;
|
|
89
|
+
const id = value.id;
|
|
90
|
+
if (typeof id !== 'string')
|
|
91
|
+
return;
|
|
92
|
+
const request = state.requests.find(item => item.id === id && item.knownTo.includes(actorId));
|
|
93
|
+
if (!request)
|
|
94
|
+
return;
|
|
95
|
+
refs.push({ path: `/requests/${index}`, inputSha256: sha256(value), kind: 'request', id,
|
|
96
|
+
evidenceEventSeq: request.openedSeq });
|
|
97
|
+
});
|
|
98
|
+
const beliefs = call.input.beliefs;
|
|
99
|
+
if (Array.isArray(beliefs))
|
|
100
|
+
beliefs.forEach((value, index) => {
|
|
101
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
102
|
+
return;
|
|
103
|
+
const id = value.id;
|
|
104
|
+
if (typeof id !== 'string')
|
|
105
|
+
return;
|
|
106
|
+
const belief = person.beliefs.find(item => item.id === id);
|
|
107
|
+
if (!belief)
|
|
108
|
+
return;
|
|
109
|
+
refs.push({ path: `/beliefs/${index}`, inputSha256: sha256(value), kind: 'belief', id,
|
|
110
|
+
evidenceEventSeq: belief.source.eventSeq });
|
|
111
|
+
});
|
|
112
|
+
const seen = new Set(refs.map(ref => ref.path));
|
|
113
|
+
const add = (path, value) => {
|
|
114
|
+
if (seen.has(path))
|
|
115
|
+
return;
|
|
116
|
+
const rootEnd = path.indexOf('/', 1);
|
|
117
|
+
const root = path.slice(1, rootEnd < 0 ? undefined : rootEnd);
|
|
118
|
+
const record = value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
119
|
+
? value : null;
|
|
120
|
+
const rootEnd0 = path.indexOf('/', 1);
|
|
121
|
+
const id = typeof record?.id === 'string' && path.slice(1, rootEnd0 < 0 ? undefined : rootEnd0) !== 'goals' ? record.id : actorId;
|
|
122
|
+
let kind = 'self';
|
|
123
|
+
let evidenceEventSeq = 0;
|
|
124
|
+
if (root === 'scene' || root === 'exits' || root === 'playerHere')
|
|
125
|
+
kind = 'present';
|
|
126
|
+
else if (root === 'directory')
|
|
127
|
+
kind = path.startsWith('/directory/people/') ? 'known-person'
|
|
128
|
+
: path.startsWith('/directory/places/') ? 'known-place' : 'known-directory';
|
|
129
|
+
else if (root === 'playerId' || root === 'playerName' || root === 'playerPronouns')
|
|
130
|
+
kind = 'known-person';
|
|
131
|
+
else if (root === 'relationships')
|
|
132
|
+
kind = 'relationship';
|
|
133
|
+
else if (root === 'pressing')
|
|
134
|
+
kind = 'pressing';
|
|
135
|
+
else if (root === 'summaries')
|
|
136
|
+
kind = 'summary';
|
|
137
|
+
else if (root === 'commitments')
|
|
138
|
+
kind = 'commitment';
|
|
139
|
+
else if (root === 'promisedToMe')
|
|
140
|
+
kind = 'promise';
|
|
141
|
+
else if (root === 'stances')
|
|
142
|
+
kind = 'stance';
|
|
143
|
+
else if (root === 'previousErrors')
|
|
144
|
+
kind = 'feedback';
|
|
145
|
+
else if (root === 'happened' || root === 'message') {
|
|
146
|
+
kind = 'event';
|
|
147
|
+
evidenceEventSeq = causeSeq ?? 0;
|
|
148
|
+
}
|
|
149
|
+
else if (root === 'duePlan')
|
|
150
|
+
kind = 'schedule';
|
|
151
|
+
const refId = root === 'scene' || root === 'exits' ? record?.id ?? person.placeId
|
|
152
|
+
: root === 'playerId' || root === 'playerName' || root === 'playerPronouns' || root === 'playerHere' ? state.playerId
|
|
153
|
+
: root === 'directory' && path.startsWith('/directory/places/') ? record?.id
|
|
154
|
+
: root === 'happened' || root === 'message' ? String(evidenceEventSeq)
|
|
155
|
+
: id;
|
|
156
|
+
if (typeof refId !== 'string')
|
|
157
|
+
return;
|
|
158
|
+
let sourceId = refId;
|
|
159
|
+
if (kind === 'schedule' && record !== null && typeof record.intent === 'string') {
|
|
160
|
+
const due = state.schedule.find(item => item.kind === 'plan' && item.personId === actorId && item.intent === record.intent);
|
|
161
|
+
if (due) {
|
|
162
|
+
sourceId = due.id;
|
|
163
|
+
evidenceEventSeq = due.sourceSeq;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (kind === 'commitment' || kind === 'promise') {
|
|
167
|
+
const made = kind === 'commitment' ? person.commitments.find(commitment => commitment.id === refId)
|
|
168
|
+
: Object.values(state.people).flatMap(other => other.commitments).find(commitment => commitment.id === refId && commitment.toId === actorId);
|
|
169
|
+
const source = state.events.find(event => event.effects.some(effect => effect.kind === 'commitment' && effect.commitmentId === refId));
|
|
170
|
+
evidenceEventSeq = made?.madeSeq ?? source?.seq ?? 0;
|
|
171
|
+
}
|
|
172
|
+
if (kind === 'stance' && typeof record?.sourceSeq === 'number')
|
|
173
|
+
evidenceEventSeq = record.sourceSeq;
|
|
174
|
+
if (kind === 'relationship') {
|
|
175
|
+
const source = [...state.events].reverse().find(event => event.effects.some(effect => effect.kind === 'relationship'
|
|
176
|
+
&& effect.personId === actorId && effect.targetId === refId));
|
|
177
|
+
evidenceEventSeq = person.relationships[refId]?.changedSeq ?? source?.seq ?? 0;
|
|
178
|
+
}
|
|
179
|
+
if (kind === 'known-place' && refId !== person.placeId
|
|
180
|
+
&& !state.places[person.placeId]?.connections.includes(refId)) {
|
|
181
|
+
evidenceEventSeq = person.memories.find(memory => memory.placeId === refId && memory.evidence)?.evidence?.eventSeq ?? 0;
|
|
182
|
+
}
|
|
183
|
+
if (kind === 'known-person' && state.people[refId]?.placeId !== person.placeId) {
|
|
184
|
+
evidenceEventSeq = person.memories.find(memory => memory.participantIds.includes(refId) && memory.evidence)?.evidence?.eventSeq
|
|
185
|
+
?? person.beliefs.find(belief => belief.source.personId === refId)?.source.eventSeq ?? 0;
|
|
186
|
+
}
|
|
187
|
+
refs.push({ path, inputSha256: sha256(value), kind, id: sourceId, evidenceEventSeq });
|
|
188
|
+
seen.add(path);
|
|
189
|
+
};
|
|
190
|
+
const visit = (value, path) => {
|
|
191
|
+
if (Array.isArray(value)) {
|
|
192
|
+
value.forEach((item, index) => visit(item, `${path}/${index}`));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
add(path, value);
|
|
196
|
+
if (value !== null && typeof value === 'object')
|
|
197
|
+
for (const [key, child] of Object.entries(value)) {
|
|
198
|
+
if (Array.isArray(child))
|
|
199
|
+
visit(child, `${path}/${key.replaceAll('~', '~0').replaceAll('/', '~1')}`);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
for (const [key, value] of Object.entries(call.input))
|
|
203
|
+
if (key !== 'phase' && key !== 'clock')
|
|
204
|
+
visit(value, `/${key.replaceAll('~', '~0').replaceAll('/', '~1')}`);
|
|
205
|
+
return refs;
|
|
206
|
+
}
|
|
207
|
+
const clampInt = (value, min, max, fallback) => typeof value === 'number' && Number.isInteger(value) ? Math.max(min, Math.min(max, value)) : fallback;
|
|
208
|
+
export function cleanMood(value, fallback) {
|
|
209
|
+
const mood = typeof value === 'string' ? value.trim().slice(0, 40) : '';
|
|
210
|
+
return mood || fallback;
|
|
211
|
+
}
|
|
212
|
+
// One command runs inside one session: the state advances as events commit, and the
|
|
213
|
+
// audit keeps a digest-only receipt of every model call.
|
|
214
|
+
class Session {
|
|
215
|
+
cell;
|
|
216
|
+
inference;
|
|
217
|
+
takenEntityIds;
|
|
218
|
+
events = [];
|
|
219
|
+
audit = [];
|
|
220
|
+
calls = 0;
|
|
221
|
+
nonPlayerMinutes = 0;
|
|
222
|
+
// How many acts made in answer to another act are being ruled now.
|
|
223
|
+
answeringActs = 0;
|
|
224
|
+
constructor(cell, inference, takenEntityIds = async () => new Set()) {
|
|
225
|
+
this.cell = cell;
|
|
226
|
+
this.inference = inference;
|
|
227
|
+
this.takenEntityIds = takenEntityIds;
|
|
228
|
+
}
|
|
229
|
+
get state() { return this.cell.state; }
|
|
230
|
+
set state(value) { this.cell.state = value; }
|
|
231
|
+
live() { return this.state.clockRate !== null; }
|
|
232
|
+
draft(fields) {
|
|
233
|
+
return { seq: this.state.revision + 1, at: this.state.clock, witnessIds: witnessesFor(this.state, fields), nearbyIds: nearbyFor(this.state, fields), ...fields };
|
|
234
|
+
}
|
|
235
|
+
commit(fields) {
|
|
236
|
+
const live = this.live();
|
|
237
|
+
// Deciding is private: a plan event shows others only the activity it starts, or nothing.
|
|
238
|
+
if (fields.kind === 'plan' && fields.observed === undefined && fields.perceivedBy === undefined)
|
|
239
|
+
fields = { ...fields, perceivedBy: [] };
|
|
240
|
+
const before = this.state;
|
|
241
|
+
this.state = applyLivingEvent(this.state, this.draft(fields));
|
|
242
|
+
// The record keeps the event as the world accepted it, with what reached people elsewhere.
|
|
243
|
+
const event = this.state.events.at(-1);
|
|
244
|
+
this.events.push(event);
|
|
245
|
+
this.cell.log?.push(event);
|
|
246
|
+
// In a live world acts do not move the clock, so only a turn-mode command counts their minutes.
|
|
247
|
+
if (!live && event.actorId !== this.state.playerId)
|
|
248
|
+
this.nonPlayerMinutes += event.minutes;
|
|
249
|
+
this.reportCounts(before, event);
|
|
250
|
+
return event;
|
|
251
|
+
}
|
|
252
|
+
// A vote the player is a member of, counted out of the player's sight, reaches the player as news where they are:
|
|
253
|
+
// the problem it settles then reads as settled to them, as it does to those who saw the count. Only the count's own
|
|
254
|
+
// settlement is news: a problem settled before the count, or in another way by the act that made it, is not.
|
|
255
|
+
reportCounts(before, event) {
|
|
256
|
+
const player = this.state.people[this.state.playerId];
|
|
257
|
+
for (const id of before.openDecisionIds ?? []) {
|
|
258
|
+
const decision = this.state.entities[id];
|
|
259
|
+
if (decision?.result === undefined || !decision.members?.includes(player.id) || decision.settles === undefined)
|
|
260
|
+
continue;
|
|
261
|
+
const tension = this.state.tensions.find(candidate => candidate.id === decision.settles);
|
|
262
|
+
const note = countNote(decision);
|
|
263
|
+
if (tension?.status !== 'settled' || tension.resolution !== note || tension.playerView?.status === 'settled')
|
|
264
|
+
continue;
|
|
265
|
+
this.commit({ kind: 'world', actorId: null, placeId: player.placeId, summary: `Word reaches ${player.name} that the votes on ${decision.name} were counted: ${decision.result}.`,
|
|
266
|
+
narration: null, outcome: null, minutes: 0, causeSeq: event.seq, importance: 6, suggestions: [], reach: 'here', notice: null, visibleTensionIds: [tension.id],
|
|
267
|
+
effects: [{ kind: 'tension.update', tensionId: tension.id, status: 'settled', note }] });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
player() { return this.state.people[this.state.playerId]; }
|
|
271
|
+
// A person decides one thing at a time across every flow on this world: a second decision waits
|
|
272
|
+
// until the first is recorded, then reads the world as it is. A flow may decide again for someone it
|
|
273
|
+
// already holds. The turn covers the person's own decision and its record, never others' answers
|
|
274
|
+
// to it, so two people who speak to each other at once never wait on each other.
|
|
275
|
+
async takeTurn(personId) {
|
|
276
|
+
const turns = (this.cell.turns ??= new Map());
|
|
277
|
+
for (let held = turns.get(personId); held !== undefined && held.session !== this; held = turns.get(personId))
|
|
278
|
+
await held.done;
|
|
279
|
+
let turn = turns.get(personId);
|
|
280
|
+
if (turn === undefined) {
|
|
281
|
+
let end = () => { };
|
|
282
|
+
const done = new Promise(resolve => { end = resolve; });
|
|
283
|
+
turn = { session: this, depth: 0, done, end };
|
|
284
|
+
turns.set(personId, turn);
|
|
285
|
+
}
|
|
286
|
+
const mine = turn;
|
|
287
|
+
mine.depth += 1;
|
|
288
|
+
let released = false;
|
|
289
|
+
return () => {
|
|
290
|
+
if (released)
|
|
291
|
+
return;
|
|
292
|
+
released = true;
|
|
293
|
+
mine.depth -= 1;
|
|
294
|
+
if (mine.depth === 0) {
|
|
295
|
+
turns.delete(personId);
|
|
296
|
+
mine.end();
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
canSpend() { return this.calls < MAX_MODEL_CALLS_PER_COMMAND && this.nonPlayerMinutes < MAX_NONPLAYER_MINUTES_PER_COMMAND; }
|
|
301
|
+
canSpendOptional() {
|
|
302
|
+
if (!this.canSpend())
|
|
303
|
+
return false;
|
|
304
|
+
const due = this.state.schedule.some(item => item.at <= this.state.clock
|
|
305
|
+
&& (item.kind === 'world' || item.kind === 'plan' || item.kind === 'reply' || item.kind === 'finish')
|
|
306
|
+
&& (item.personId === null || (!this.state.people[item.personId]?.incapacitated && !this.state.people[item.personId]?.isPlayer)));
|
|
307
|
+
return !due || this.calls < MAX_MODEL_CALLS_PER_COMMAND - MAX_DUE_ITEMS_PER_STEP;
|
|
308
|
+
}
|
|
309
|
+
// With build, a host that makes the call wait may build it again from the world as it is when it goes.
|
|
310
|
+
async complete(call, actorId, causeSeq = null, build, wanted = () => true, lookup = true) {
|
|
311
|
+
if (this.state.paused)
|
|
312
|
+
throw new LivingWorldError('run-paused', 'The world is paused');
|
|
313
|
+
if (!this.canSpend())
|
|
314
|
+
throw new LivingWorldError('inference-unavailable', 'The world-turn budget is exhausted');
|
|
315
|
+
const originalSystem = call.system;
|
|
316
|
+
const selecting = lookup && build !== undefined && call.input.view === MODEL_VIEW_NOTE;
|
|
317
|
+
if (selecting) {
|
|
318
|
+
if (this.calls + 2 > MAX_MODEL_CALLS_PER_COMMAND)
|
|
319
|
+
throw new LivingWorldError('inference-unavailable', 'The world-turn budget cannot fit context selection and a decision');
|
|
320
|
+
call = { ...call, responseSchema: withContextLookup(call.responseSchema, call.input), input: contextSelectionInput(call.input),
|
|
321
|
+
system: 'Select context for the next decision. The input shows only part of the world. Read the current action, speech, message or plan. If it names a needed person whose ID is absent from the shown cards, request that exact name or ID in contextRequest. Never substitute someone else or infer that an omitted person is absent. Request at most two identities. Use an empty array when the shown people suffice. Do not decide or narrate the action yet.',
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
assertLivingModelCall(call);
|
|
325
|
+
const startedAt = performance.now();
|
|
326
|
+
let sent = { input: call.input, responseSchema: call.responseSchema, revision: this.state.revision, inputRefs: characterInputRefs(this.state, call, actorId, causeSeq) };
|
|
327
|
+
let builtFrom = this.state;
|
|
328
|
+
// Built again only when the world has changed since.
|
|
329
|
+
const rebuild = build === undefined ? undefined : () => {
|
|
330
|
+
if (this.state !== builtFrom) {
|
|
331
|
+
builtFrom = this.state;
|
|
332
|
+
const next = build();
|
|
333
|
+
if (selecting) {
|
|
334
|
+
next.responseSchema = call.responseSchema;
|
|
335
|
+
next.input = contextSelectionInput(next.input);
|
|
336
|
+
}
|
|
337
|
+
assertLivingModelCall({ ...call, ...next });
|
|
338
|
+
sent = { ...next, revision: this.state.revision, inputRefs: characterInputRefs(this.state, { ...call, ...next }, actorId, causeSeq) };
|
|
339
|
+
}
|
|
340
|
+
return sent;
|
|
341
|
+
};
|
|
342
|
+
this.calls += 1;
|
|
343
|
+
let answer;
|
|
344
|
+
try {
|
|
345
|
+
answer = await this.inference.complete({ ...call, actorId, ...sent,
|
|
346
|
+
stillWanted: () => !this.state.paused && this.state.status !== 'finished' && wanted(),
|
|
347
|
+
reserveRetry: () => {
|
|
348
|
+
if (!this.canSpend())
|
|
349
|
+
return false;
|
|
350
|
+
this.calls += 1;
|
|
351
|
+
return true;
|
|
352
|
+
}, ...(rebuild === undefined ? {} : { rebuild }) });
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
this.audit.push({ phase: call.phase, role: call.role, actorId, status: 'failed', failureCode: error instanceof LivingWorldError ? error.code : 'inference-unavailable', inputSha256: sha256(sent.input), outputSha256: null, latencyMs: Math.round(performance.now() - startedAt) });
|
|
356
|
+
// The provider's own error stays attached, so the app can tell a rejected account from an outage.
|
|
357
|
+
throw error instanceof LivingWorldError ? error : new LivingWorldError('inference-unavailable', error instanceof Error ? error.message : 'The model did not answer', { cause: error });
|
|
358
|
+
}
|
|
359
|
+
this.audit.push({ phase: call.phase, role: call.role, actorId, status: 'accepted', failureCode: null, inputSha256: sha256(sent.input), outputSha256: sha256(answer), latencyMs: Math.round(performance.now() - startedAt) });
|
|
360
|
+
// A queued rebuild can need selection for the first time. Discard that proposal before any effects.
|
|
361
|
+
if (!selecting && lookup && build && sent.input.view === MODEL_VIEW_NOTE) {
|
|
362
|
+
return await this.complete({ ...call, ...build() }, actorId, causeSeq, build, wanted);
|
|
363
|
+
}
|
|
364
|
+
const requested = answer !== null && typeof answer === 'object' ? answer.contextRequest : undefined;
|
|
365
|
+
if (selecting || Array.isArray(requested) && requested.length > 0) {
|
|
366
|
+
if (!lookup || !build || !Array.isArray(requested) || requested.length > 2 || !requested.every(reference => typeof reference === 'string' && reference.length > 0 && reference.length <= 200)) {
|
|
367
|
+
this.rejectLast('inference-invalid');
|
|
368
|
+
throw new LivingWorldError('inference-invalid', 'A context lookup allows at most two exact identities and one follow-up');
|
|
369
|
+
}
|
|
370
|
+
const { focusIds, unavailable } = modelContextPeople(this.state, actorId, call.phase, sent.input, requested);
|
|
371
|
+
const focused = () => {
|
|
372
|
+
const next = build(focusIds);
|
|
373
|
+
return { ...next, input: { ...next.input, ...(unavailable.length === 0 ? {} : { contextLookup: { unavailable } }) } };
|
|
374
|
+
};
|
|
375
|
+
return await this.complete({ ...call, system: originalSystem, ...focused() }, actorId, causeSeq, focused, wanted, false);
|
|
376
|
+
}
|
|
377
|
+
return answer;
|
|
378
|
+
}
|
|
379
|
+
rejectLast(reason) {
|
|
380
|
+
const last = this.audit.at(-1);
|
|
381
|
+
if (last) {
|
|
382
|
+
last.status = 'rejected';
|
|
383
|
+
last.failureCode = reason;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
step() { return { state: this.state, events: this.events, audit: this.audit, modelCalls: this.calls }; }
|
|
387
|
+
}
|
|
388
|
+
const quiet = { reach: 'here', notice: null };
|
|
389
|
+
// Time away for home, sleep or an errand: a quarter hour to a day.
|
|
390
|
+
const AWAY_MINUTES = { min: 15, max: 1440 };
|
|
391
|
+
// Someone who goes home at night, or after a long day up, stays at least the night; by day, at least
|
|
392
|
+
// two hours, so no one goes home every half hour and no one leaves at four for the whole evening.
|
|
393
|
+
const GO_HOME_MINUTES = 480;
|
|
394
|
+
const DAY_AWAY_MINUTES = 120;
|
|
395
|
+
const NO_MODEL = { complete: async () => { throw new LivingWorldError('inference-unavailable', 'This step needs no model'); } };
|
|
396
|
+
// A live world starts on the server clock at nowMs; a world without a clock rate moves with its events.
|
|
397
|
+
export function startLivingWorld(definition, seed, nowMs) {
|
|
398
|
+
const initial = createLivingState(definition, seed);
|
|
399
|
+
const session = new Session({ state: initial }, NO_MODEL);
|
|
400
|
+
const authoredEffects = [
|
|
401
|
+
...definition.kinds.map(kind => ({ kind: 'kind.define', definition: kind })),
|
|
402
|
+
...definition.entities.map(entity => ({ kind: 'entity.create', entityId: entity.id, kindId: entity.kindId, name: entity.name, placeId: entity.placeId,
|
|
403
|
+
...(entity.members ? { memberIds: entity.members } : {}), ...(entity.settles ? { settles: entity.settles } : {}), ...(entity.transfers ? { transfers: entity.transfers } : {}),
|
|
404
|
+
...(entity.knownTo ? { knownTo: entity.knownTo } : {}), ...(entity.hidden ? { hidden: true } : {}) })),
|
|
405
|
+
// A paper is sealed after its words are written, whatever order its world lists them in.
|
|
406
|
+
...definition.entities.flatMap(entity => Object.entries(entity.properties).sort(([left], [right]) => Number(left === 'sealed') - Number(right === 'sealed')).map(([propertyId, value]) => ({ kind: 'property.set', entityId: entity.id, propertyId, value }))),
|
|
407
|
+
...definition.entities.flatMap(entity => Object.entries(entity.relations).map(([relationId, targetId]) => ({ kind: 'relation.set', entityId: entity.id, relationId, targetId }))),
|
|
408
|
+
];
|
|
409
|
+
session.commit({
|
|
410
|
+
kind: 'opening', actorId: null, placeId: initial.people[initial.playerId].placeId, summary: initial.opening.title, narration: initial.opening.situation,
|
|
411
|
+
outcome: null, minutes: 0, effects: authoredEffects, causeSeq: null, importance: 8, suggestions: [], ...quiet,
|
|
412
|
+
});
|
|
413
|
+
if (definition.clock) {
|
|
414
|
+
if (nowMs === undefined)
|
|
415
|
+
throw new LivingWorldError('command-invalid', 'A live world starts at a real time');
|
|
416
|
+
session.commit(clockFields(false, definition.clock.rate, nowMs));
|
|
417
|
+
}
|
|
418
|
+
return session.step();
|
|
419
|
+
}
|
|
420
|
+
function clockFields(paused, rate, nowMs) {
|
|
421
|
+
return {
|
|
422
|
+
kind: 'clock', actorId: null, placeId: null, summary: paused ? 'The world is paused.' : `The world runs at ${rate} game minutes a minute.`,
|
|
423
|
+
narration: null, outcome: null, minutes: 0, effects: [{ kind: 'clock', paused, rate }], causeSeq: null, importance: 1, suggestions: [],
|
|
424
|
+
realAt: Math.floor(nowMs), ...quiet,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
// The game time the server clock has reached at a real time.
|
|
428
|
+
export function livingClockTarget(state, nowMs) {
|
|
429
|
+
return clockTargetAt(state, nowMs);
|
|
430
|
+
}
|
|
431
|
+
// Pauses, resumes or changes the speed of a live world, or makes a turn-mode world live.
|
|
432
|
+
// The game time already reached is kept; after a pause the world resumes from that moment.
|
|
433
|
+
export function setLivingClock(options) {
|
|
434
|
+
const session = new Session(options.cell, NO_MODEL);
|
|
435
|
+
const state = session.state;
|
|
436
|
+
if (state.status === 'finished')
|
|
437
|
+
throw new LivingWorldError('run-finished', 'This story has finished');
|
|
438
|
+
const rate = options.rate ?? state.clockRate;
|
|
439
|
+
if (rate === null || !Number.isInteger(rate) || rate < 1 || rate > MAX_CLOCK_RATE) {
|
|
440
|
+
throw new LivingWorldError('command-invalid', `The clock runs at 1 to ${MAX_CLOCK_RATE} game minutes a minute`);
|
|
441
|
+
}
|
|
442
|
+
const paused = options.paused ?? state.paused;
|
|
443
|
+
if (paused === state.paused && rate === state.clockRate)
|
|
444
|
+
return session.step();
|
|
445
|
+
session.commit(clockFields(paused, rate, options.nowMs));
|
|
446
|
+
return session.step();
|
|
447
|
+
}
|
|
448
|
+
// The next moment after now that something is due; the clock never passes it unsettled.
|
|
449
|
+
function nextDueAfter(state) {
|
|
450
|
+
let next = null;
|
|
451
|
+
for (const list of [state.schedule, state.meterSchedule]) {
|
|
452
|
+
const item = list.find(candidate => candidate.at > state.clock);
|
|
453
|
+
if (item && (next === null || item.at < next))
|
|
454
|
+
next = item.at;
|
|
455
|
+
}
|
|
456
|
+
return next;
|
|
457
|
+
}
|
|
458
|
+
// Moves a live world's clock toward the server clock, stopping at the next due item and at an
|
|
459
|
+
// optional limit, such as a host keeping the clock near decisions still waiting on the model.
|
|
460
|
+
// With idleMinutes, a stretch in which nothing falls due is recorded only once it is that long.
|
|
461
|
+
export function tickLivingWorld(cell, nowMs, options = {}) {
|
|
462
|
+
const state = cell.state;
|
|
463
|
+
if (state.clockRate === null || state.paused || state.status === 'finished')
|
|
464
|
+
return null;
|
|
465
|
+
// The tick records the whole millisecond it happened at, and reaches no further than that moment allows.
|
|
466
|
+
const realAt = Math.floor(nowMs);
|
|
467
|
+
const target = clockTargetAt(state, realAt);
|
|
468
|
+
const next = nextDueAfter(state);
|
|
469
|
+
const dueSoon = next !== null && next <= target;
|
|
470
|
+
if (!dueSoon && options.idleMinutes !== undefined && target - state.clock < options.idleMinutes)
|
|
471
|
+
return null;
|
|
472
|
+
const to = Math.min(target, next ?? Number.POSITIVE_INFINITY, options.limit ?? Number.POSITIVE_INFINITY);
|
|
473
|
+
if (to <= state.clock)
|
|
474
|
+
return null;
|
|
475
|
+
return new Session(cell, NO_MODEL).commit({
|
|
476
|
+
kind: 'tick', actorId: null, placeId: null, summary: `The clock reaches ${clockLabel(to)}.`, narration: null, outcome: null,
|
|
477
|
+
minutes: to - state.clock, effects: [], causeSeq: null, importance: 1, suggestions: [], realAt, ...quiet,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
function recoveringPeople(state) {
|
|
481
|
+
return Object.values(state.people).filter(person => person.incapacitatedBy !== undefined && person.departed === undefined
|
|
482
|
+
&& blockedByGate(state, person.id, 'incapacitate') === null).map(person => person.id);
|
|
483
|
+
}
|
|
484
|
+
// Everything the world has to do now, most pressing first: recoveries, due items, then anyone
|
|
485
|
+
// in a live world with nothing to do, who decides at once.
|
|
486
|
+
export function livingDueWork(state) {
|
|
487
|
+
if (state.paused || state.status === 'finished')
|
|
488
|
+
return [];
|
|
489
|
+
const work = recoveringPeople(state).map(personId => ({ kind: 'recover', personId }));
|
|
490
|
+
const due = [];
|
|
491
|
+
for (const list of [state.schedule, state.meterSchedule])
|
|
492
|
+
for (const item of list) {
|
|
493
|
+
if (item.at > state.clock)
|
|
494
|
+
break;
|
|
495
|
+
if (predictionReady(state, item))
|
|
496
|
+
due.push(item);
|
|
497
|
+
}
|
|
498
|
+
due.sort((left, right) => right.importance - left.importance || left.at - right.at || left.id.localeCompare(right.id));
|
|
499
|
+
work.push(...due.map((item) => ({ kind: 'due', item })));
|
|
500
|
+
if (state.clockRate !== null) {
|
|
501
|
+
for (const person of Object.values(state.people)) {
|
|
502
|
+
if (!person.isPlayer && !person.incapacitated && !person.departed && !hasPlanned(state, person.id))
|
|
503
|
+
work.push({ kind: 'decide', personId: person.id });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return work;
|
|
507
|
+
}
|
|
508
|
+
// Carries out one piece of the world's own work in its own session and call budget. Work that
|
|
509
|
+
// another session already settled is skipped. Settled is false when the model could not answer.
|
|
510
|
+
export async function runLivingWork(options) {
|
|
511
|
+
const session = new Session(options.cell, options.inference, options.takenEntityIds);
|
|
512
|
+
if (session.state.paused || session.state.status === 'finished')
|
|
513
|
+
return { ...session.step(), settled: false };
|
|
514
|
+
joinAvailableKinds(session, options.availableKinds ?? []);
|
|
515
|
+
const { work } = options;
|
|
516
|
+
const state = session.state;
|
|
517
|
+
let settled = true;
|
|
518
|
+
if (work.kind === 'recover') {
|
|
519
|
+
const person = state.people[work.personId];
|
|
520
|
+
if (person && recoveringPeople(state).includes(person.id)) {
|
|
521
|
+
session.commit({ kind: 'world', actorId: null, placeId: person.placeId, summary: `${person.name} can act again.`, narration: null, outcome: null, minutes: 0,
|
|
522
|
+
effects: [], causeSeq: null, importance: 2, suggestions: [], ...quiet });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
else if (work.kind === 'decide') {
|
|
526
|
+
const person = state.people[work.personId];
|
|
527
|
+
if (person && !person.isPlayer && !person.incapacitated && !person.departed && !hasPlanned(state, person.id)) {
|
|
528
|
+
settled = await carryOutPlan(session, { id: generatedId(state.engine, 'idle', [state.revision + 1, person.id]), at: state.clock, personId: person.id, kind: 'plan', intent: 'idle',
|
|
529
|
+
placeId: null, targetId: null, sourceSeq: state.revision, importance: 1 }, false);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
const due = [...state.schedule, ...state.meterSchedule].find(item => item.id === work.item.id);
|
|
534
|
+
if (due && due.at <= state.clock)
|
|
535
|
+
settled = await settleItem(session, due);
|
|
536
|
+
}
|
|
537
|
+
return { ...session.step(), settled };
|
|
538
|
+
}
|
|
539
|
+
// Whether a person has something planned now; a promise to keep later is not a plan for now.
|
|
540
|
+
function hasPlanned(state, personId) {
|
|
541
|
+
return state.schedule.some(item => item.personId === personId && !item.id.startsWith('keep-'));
|
|
542
|
+
}
|
|
543
|
+
// A decision a person makes about their own routine: no one else's act, message or happening waits on it.
|
|
544
|
+
export function livingWorkIsRoutine(work) {
|
|
545
|
+
if (work.kind === 'decide')
|
|
546
|
+
return true;
|
|
547
|
+
return work.kind === 'due' && work.item.kind === 'plan' && work.item.personId !== null
|
|
548
|
+
&& work.item.importance <= ROUTINE_PLAN_IMPORTANCE && !isDecision(work.item);
|
|
549
|
+
}
|
|
550
|
+
// Someone who has kept one activity for two game hours, carried on without deciding, is due a real
|
|
551
|
+
// decision however far behind the world is, so no one stands in one plan for a whole day.
|
|
552
|
+
const CARRY_ON_LIMIT_MINUTES = 120;
|
|
553
|
+
function carriedOnTooLong(state, person) {
|
|
554
|
+
const history = person.planHistory ?? [];
|
|
555
|
+
const last = history.at(-1);
|
|
556
|
+
if (!last)
|
|
557
|
+
return false;
|
|
558
|
+
let since = last.at;
|
|
559
|
+
for (let index = history.length - 1; index >= 0 && history[index].intent === last.intent; index -= 1)
|
|
560
|
+
since = history[index].at;
|
|
561
|
+
return state.clock - since >= CARRY_ON_LIMIT_MINUTES;
|
|
562
|
+
}
|
|
563
|
+
// When a world falls behind its clock, someone with nothing pressing carries on with what they were
|
|
564
|
+
// doing a while longer instead of waiting for the model, as when a decision names no activity. While
|
|
565
|
+
// the model cannot answer at all, they carry on however long they have, since no decision can be made.
|
|
566
|
+
export function carryOnLivingWork(options) {
|
|
567
|
+
const session = new Session(options.cell, NO_MODEL);
|
|
568
|
+
const state = session.state;
|
|
569
|
+
const { work } = options;
|
|
570
|
+
const personId = work.kind === 'decide' ? work.personId : work.kind === 'due' ? work.item.personId : null;
|
|
571
|
+
const person = personId === null ? undefined : state.people[personId];
|
|
572
|
+
const current = work.kind === 'due' ? state.schedule.some(item => item.id === work.item.id)
|
|
573
|
+
: work.kind === 'decide' && personId !== null && !hasPlanned(state, personId);
|
|
574
|
+
// Someone with a decision out is left to it; carrying them on meanwhile would overtake it.
|
|
575
|
+
if (state.paused || state.status === 'finished' || !livingWorkIsRoutine(work) || !current || !person || person.isPlayer || person.incapacitated || person.departed
|
|
576
|
+
|| options.cell.turns?.has(person.id) || !options.modelDown && carriedOnTooLong(state, person)) {
|
|
577
|
+
return { ...session.step(), settled: false };
|
|
578
|
+
}
|
|
579
|
+
const plan = { intent: person.plan?.intent ?? 'Carry on with the day', ...(person.plan?.seen ? { seen: person.plan.seen } : {}),
|
|
580
|
+
placeId: null, targetId: null, inMinutes: DEFAULT_ACTIVITY_MINUTES };
|
|
581
|
+
session.commit({ kind: 'plan', actorId: person.id, placeId: person.placeId, summary: `${person.name} carries on: ${plan.intent}`.slice(0, 2000), narration: null, outcome: null,
|
|
582
|
+
minutes: 0, effects: planEffects(state, person.id, plan, state.revision + 1), causeSeq: null, importance: 1, suggestions: [], ...quiet });
|
|
583
|
+
return { ...session.step(), settled: true };
|
|
584
|
+
}
|
|
585
|
+
// Brings a live world up to the server clock: due work settles in order as the clock passes it.
|
|
586
|
+
// Work the model cannot answer now stays due and the clock goes on. A paused world does nothing.
|
|
587
|
+
export async function advanceLivingWorld(options) {
|
|
588
|
+
const events = [];
|
|
589
|
+
const audit = [];
|
|
590
|
+
let modelCalls = 0;
|
|
591
|
+
const failed = new Set();
|
|
592
|
+
const key = (work) => work.kind === 'due' ? `due:${work.item.id}` : `${work.kind}:${work.personId}`;
|
|
593
|
+
for (let guard = 0; guard < 100_000; guard += 1) {
|
|
594
|
+
if (options.cell.state.paused)
|
|
595
|
+
break;
|
|
596
|
+
const work = livingDueWork(options.cell.state).find(item => !failed.has(key(item)));
|
|
597
|
+
if (work) {
|
|
598
|
+
const step = await runLivingWork({ ...options, work });
|
|
599
|
+
events.push(...step.events);
|
|
600
|
+
audit.push(...step.audit);
|
|
601
|
+
modelCalls += step.modelCalls;
|
|
602
|
+
if (!step.settled || step.events.length === 0)
|
|
603
|
+
failed.add(key(work));
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const tick = tickLivingWorld(options.cell, options.nowMs);
|
|
607
|
+
if (!tick)
|
|
608
|
+
break;
|
|
609
|
+
events.push(tick);
|
|
610
|
+
failed.clear();
|
|
611
|
+
}
|
|
612
|
+
return { state: options.cell.state, events, audit, modelCalls };
|
|
613
|
+
}
|
|
614
|
+
// takenEntityIds tells which proposed ids already belong to saved things this command did not load.
|
|
615
|
+
// A live world takes the command at the present moment of its server clock and settles nothing
|
|
616
|
+
// else: its own due work runs beside the command. A turn-mode world settles what fell due after it.
|
|
617
|
+
export async function runLivingCommand(options) {
|
|
618
|
+
const cell = options.cell ?? { state: options.state };
|
|
619
|
+
if (cell.state.status === 'finished')
|
|
620
|
+
throw new LivingWorldError('run-finished', 'This story has finished');
|
|
621
|
+
if (cell.state.paused)
|
|
622
|
+
throw new LivingWorldError('run-paused', 'The world is paused. Resume it to act.');
|
|
623
|
+
if (cell.state.people[cell.state.playerId].incapacitated && options.command.kind !== 'finish') {
|
|
624
|
+
throw new LivingWorldError('command-invalid', 'You cannot act now');
|
|
625
|
+
}
|
|
626
|
+
const live = cell.state.clockRate !== null;
|
|
627
|
+
if (live && options.command.kind === 'wait')
|
|
628
|
+
throw new LivingWorldError('command-invalid', 'Time passes on its own here. Change the speed to make it pass faster.');
|
|
629
|
+
if (live && options.nowMs === undefined)
|
|
630
|
+
throw new LivingWorldError('command-invalid', 'A live world takes commands at a real time');
|
|
631
|
+
const session = new Session(cell, options.inference, options.takenEntityIds);
|
|
632
|
+
if (live) {
|
|
633
|
+
const tick = tickLivingWorld(cell, options.nowMs, options.tickLimit === undefined ? {} : { limit: options.tickLimit });
|
|
634
|
+
if (tick)
|
|
635
|
+
session.events.push(tick);
|
|
636
|
+
}
|
|
637
|
+
joinAvailableKinds(session, options.availableKinds ?? []);
|
|
638
|
+
const { command } = options;
|
|
639
|
+
try {
|
|
640
|
+
switch (command.kind) {
|
|
641
|
+
case 'move':
|
|
642
|
+
await movePlayer(session, command.placeId);
|
|
643
|
+
break;
|
|
644
|
+
case 'say':
|
|
645
|
+
await playerSpeaks(session, command.text, command.toIds);
|
|
646
|
+
break;
|
|
647
|
+
case 'attempt':
|
|
648
|
+
await adjudicate(session, session.state.playerId, requireText(command.text));
|
|
649
|
+
break;
|
|
650
|
+
case 'message':
|
|
651
|
+
playerMessages(session, command.toIds, command.text);
|
|
652
|
+
break;
|
|
653
|
+
case 'wait':
|
|
654
|
+
await playerWaits(session);
|
|
655
|
+
break;
|
|
656
|
+
case 'keep-playing':
|
|
657
|
+
keepPlaying(session);
|
|
658
|
+
break;
|
|
659
|
+
case 'finish':
|
|
660
|
+
await finish(session);
|
|
661
|
+
return session.step();
|
|
662
|
+
case 'vote':
|
|
663
|
+
playerVotes(session, command.decisionId, command.choice);
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
catch (error) {
|
|
668
|
+
// In a live world the player's own act stands once it is recorded. What others would say or do
|
|
669
|
+
// about it but cannot now, for a silent model or a pause, is left to them; the command succeeds.
|
|
670
|
+
const recorded = session.events.some(event => event.actorId === session.state.playerId && event.kind !== 'tick');
|
|
671
|
+
if (!live || !recorded || !(error instanceof LivingWorldError) || !LATER_CODES.has(error.code))
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
674
|
+
if (!live)
|
|
675
|
+
await settleDue(session);
|
|
676
|
+
return session.step();
|
|
677
|
+
}
|
|
678
|
+
// A defined kind joins this world once every kind it links to exists here; until then it stays requested.
|
|
679
|
+
function joinAvailableKinds(session, availableKinds) {
|
|
680
|
+
let waiting = availableKinds.filter(definition => session.state.kindRequests.some(request => request.id === definition.id) && !session.state.kinds[definition.id]);
|
|
681
|
+
for (;;) {
|
|
682
|
+
const ready = waiting.filter(definition => definition.relations.every(relation => relation.targetKindId === definition.id || session.state.kinds[relation.targetKindId]));
|
|
683
|
+
if (ready.length === 0)
|
|
684
|
+
break;
|
|
685
|
+
for (const definition of ready) {
|
|
686
|
+
// A stored definition that today's checks reject stays out of this world; the command goes on.
|
|
687
|
+
try {
|
|
688
|
+
session.commit({
|
|
689
|
+
kind: 'world', actorId: null, placeId: null, summary: `Kind ${definition.id} becomes available.`,
|
|
690
|
+
narration: null, outcome: null, minutes: 0, effects: [{ kind: 'kind.define', definition }],
|
|
691
|
+
causeSeq: null, importance: 1, suggestions: [], ...quiet,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
catch (error) {
|
|
695
|
+
if (!(error instanceof LivingWorldError) || error.code !== 'effect-invalid')
|
|
696
|
+
throw error;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
waiting = waiting.filter(definition => !ready.includes(definition));
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
function requireText(text) {
|
|
703
|
+
const trimmed = text.trim();
|
|
704
|
+
if (trimmed === '' || Buffer.byteLength(trimmed, 'utf8') > PLAYER_MAX_TEXT_BYTES)
|
|
705
|
+
throw new LivingWorldError('command-invalid', `Say or try something in 1 to ${PLAYER_MAX_TEXT_BYTES} bytes`);
|
|
706
|
+
return trimmed;
|
|
707
|
+
}
|
|
708
|
+
function presentCharacters(state, placeId, except = null) {
|
|
709
|
+
return peopleAt(state, placeId).filter(id => id !== except && !state.people[id].isPlayer && !state.people[id].incapacitated);
|
|
710
|
+
}
|
|
711
|
+
// ---- Player commands -----------------------------------------------------------
|
|
712
|
+
async function movePlayer(session, placeId) {
|
|
713
|
+
const route = () => {
|
|
714
|
+
const player = session.player();
|
|
715
|
+
const path = travelPath(session.state, player.placeId, placeId);
|
|
716
|
+
if (path === null)
|
|
717
|
+
throw new LivingWorldError('place-unreachable', 'That place cannot be reached from here');
|
|
718
|
+
if (path.length === 1)
|
|
719
|
+
throw new LivingWorldError('command-invalid', 'You are already there');
|
|
720
|
+
if (player.incapacitated)
|
|
721
|
+
throw new LivingWorldError('command-invalid', 'You cannot move now');
|
|
722
|
+
if (player.held && heldHere(session.state, player.id))
|
|
723
|
+
throw new LivingWorldError('command-invalid', `You cannot leave: ${player.held.reason}. Try something if you mean to get away.`);
|
|
724
|
+
const gate = blockedByGate(session.state, player.id, 'move');
|
|
725
|
+
if (gate) {
|
|
726
|
+
// The player hears what keeps them by the name they know it by, never its id.
|
|
727
|
+
const entity = session.state.entities[gate];
|
|
728
|
+
const known = entity && entity.knownTo.includes(player.id) ? entity.name : 'Something';
|
|
729
|
+
throw new LivingWorldError('command-invalid', `You cannot leave: ${known} keeps you here. Try something if you mean to get away.`);
|
|
730
|
+
}
|
|
731
|
+
return path;
|
|
732
|
+
};
|
|
733
|
+
route();
|
|
734
|
+
// A long act whose time has passed ends before the walk; its end may have moved or stopped the player.
|
|
735
|
+
await finishDue(session, session.state.playerId);
|
|
736
|
+
const path = route();
|
|
737
|
+
const player = session.player();
|
|
738
|
+
// A long act still under way is left behind, and the player is told so.
|
|
739
|
+
const leftBehind = session.state.engine >= WALK_OFF_ENGINE ? session.state.schedule.filter(item => item.kind === 'finish' && item.targetId === player.id).map(leaveOff) : [];
|
|
740
|
+
session.commit({
|
|
741
|
+
kind: 'move', actorId: player.id, placeId: player.placeId,
|
|
742
|
+
summary: `${player.name} leaves for ${session.state.places[placeId].name}`, narration: null, outcome: null,
|
|
743
|
+
minutes: (path.length - 1) * TRAVEL_MINUTES_PER_HOP, effects: [...leftBehind, { kind: 'move', personId: player.id, placeId }],
|
|
744
|
+
causeSeq: null, importance: 1, suggestions: [], ...quiet,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
// The gate that blocks a kind of act for a person now, by name, or null.
|
|
748
|
+
function gateAgainst(state, personId, actionKind) {
|
|
749
|
+
return gatesAgainst(state, personId, actionKind)[0] ?? null;
|
|
750
|
+
}
|
|
751
|
+
async function playerSpeaks(session, rawText, rawToIds) {
|
|
752
|
+
const gag = gateAgainst(session.state, session.state.playerId, 'speak');
|
|
753
|
+
if (gag)
|
|
754
|
+
throw new LivingWorldError('command-invalid', `You cannot speak now: ${gag.name}`);
|
|
755
|
+
const text = requireText(rawText);
|
|
756
|
+
const player = session.player();
|
|
757
|
+
const present = presentCharacters(session.state, player.placeId);
|
|
758
|
+
const toIds = [...new Set(rawToIds)];
|
|
759
|
+
for (const id of toIds)
|
|
760
|
+
if (!present.includes(id))
|
|
761
|
+
throw new LivingWorldError('person-absent', `${personLabel(session.state, id)} is not here`);
|
|
762
|
+
let listeners = toIds.length > 0 ? byTurn(session.state, player.placeId, toIds).slice(0, MAX_REACTORS) : chooseListeners(session.state, present);
|
|
763
|
+
let addressed = [...toIds];
|
|
764
|
+
let about = [];
|
|
765
|
+
// Things here or in the speaker's hand that the words could be about.
|
|
766
|
+
// The newest things here that the speaker knows of, as a scene lists them.
|
|
767
|
+
// From engine 49 words may also be about what the speaker knows lies elsewhere, the latest changed first.
|
|
768
|
+
// However full the room, eight are named: what the speaker holds, then the newest here, then elsewhere.
|
|
769
|
+
const known = (session.state.entityPlaceIds[player.placeId] ?? []).filter(id => session.state.entities[id].knownTo.includes(player.id));
|
|
770
|
+
const objectsHere = Object.values(session.state.objects).filter(object => (object.placeId === player.placeId || object.holderId === player.id)
|
|
771
|
+
&& (!object.hidden || object.holderId === player.id || object.custodyKnownTo.includes(player.id)));
|
|
772
|
+
const thingsHere = session.state.engine < THING_MEMORY_ENGINE ? [] : [...new Set([
|
|
773
|
+
...known.filter(id => session.state.entities[id].holderId === player.id), ...objectsHere.filter(object => object.holderId === player.id).map(object => object.id),
|
|
774
|
+
...[...known].reverse(), ...[...objectsHere].sort((left, right) => right.introducedAt - left.introducedAt || left.id.localeCompare(right.id)).map(object => object.id),
|
|
775
|
+
...(session.state.engine < THINGS_SEEN_ENGINE ? [] : knownElsewhere(session.state, player.id, player.placeId))
|
|
776
|
+
])].slice(0, SPEECH_THINGS).sort();
|
|
777
|
+
// People the player has seen who are elsewhere may be the ones meant; such words go to the room.
|
|
778
|
+
const elsewhere = Object.keys(session.state.sightings).filter(id => !present.includes(id) && session.state.people[id] !== undefined
|
|
779
|
+
&& session.state.people[id].departed === undefined && id !== player.id).sort();
|
|
780
|
+
// Whom the player was just talking with, from the talk under way here.
|
|
781
|
+
const talk = session.state.conversations.filter(conversation => conversation.placeId === player.placeId && conversation.endedAt === null)
|
|
782
|
+
.flatMap(conversation => conversation.lines).sort((left, right) => left.seq - right.seq);
|
|
783
|
+
const exchange = { youLastSpokeTo: [...(talk.filter(line => line.speakerId === player.id).at(-1)?.toIds ?? [])],
|
|
784
|
+
lastSpokeToYou: talk.filter(line => line.speakerId !== player.id && line.toIds.includes(player.id)).at(-1)?.speakerId ?? null };
|
|
785
|
+
let importance = 3;
|
|
786
|
+
let pressure = null;
|
|
787
|
+
let promised = null;
|
|
788
|
+
let loud = false;
|
|
789
|
+
const nearbyListeners = nearbyFor(session.state, { placeId: player.placeId, reach: 'nearby', effects: [] });
|
|
790
|
+
if (listeners.length > 0 || nearbyListeners.length > 0) {
|
|
791
|
+
try {
|
|
792
|
+
const talking = new Set([...toIds, ...exchange.youLastSpokeTo, ...(exchange.lastSpokeToYou ? [exchange.lastSpokeToYou] : [])]);
|
|
793
|
+
const held = new Set([...Object.values(session.state.objects).filter(object => object.holderId === player.id).map(object => object.id),
|
|
794
|
+
...thingsHere.filter(id => session.state.entities[id]?.holderId === player.id)]);
|
|
795
|
+
const build = (focusIds = []) => {
|
|
796
|
+
const input = fitSpeechInput(session.state, { speaker: { name: player.name, role: player.role }, words: text,
|
|
797
|
+
listeners: present.map(id => ({ id, name: session.state.people[id].name, role: session.state.people[id].role })),
|
|
798
|
+
...(elsewhere.length === 0 ? {} : { elsewhere: elsewhere.map(id => ({ id, name: session.state.people[id].name })) }),
|
|
799
|
+
exchange,
|
|
800
|
+
...(thingsHere.length === 0 ? {} : { things: thingsHere.map(id => ({ id, name: session.state.entities[id]?.name ?? session.state.objects[id].name })) }),
|
|
801
|
+
setting: speechSetting(session.state), place: session.state.places[player.placeId].name }, new Set([...focusIds, ...talking]), held);
|
|
802
|
+
const ids = (list) => (list ?? []).map(item => item.id);
|
|
803
|
+
return { input, responseSchema: scopedSpeechImpactResponseSchema([...ids(input.listeners), ...ids(input.elsewhere)], ids(input.things)) };
|
|
804
|
+
};
|
|
805
|
+
const assessment = await session.complete({
|
|
806
|
+
role: 'game-master', phase: 'speech-impact', system: SPEECH_IMPACT_SYSTEM, ...build(), ...LIVING_INFERENCE_LIMITS['speech-impact'],
|
|
807
|
+
}, player.id, null, build);
|
|
808
|
+
importance = clampInt(assessment.importance, 1, 10, 3);
|
|
809
|
+
// A line presses on its listeners; a promise out loud is one they are owed from now on.
|
|
810
|
+
const pressed = assessment.pressure !== null && typeof assessment.pressure === 'object' ? assessment.pressure
|
|
811
|
+
: typeof assessment.pressure === 'string' ? { kind: 'demand', text: assessment.pressure, dueInMinutes: null } : null;
|
|
812
|
+
pressure = pressed && typeof pressed.text === 'string' && pressed.text.trim() !== '' ? pressed.text.trim().slice(0, 200) : null;
|
|
813
|
+
if (pressure !== null && pressed?.kind === 'promise')
|
|
814
|
+
promised = { text: pressure, dueInMinutes: typeof pressed.dueInMinutes === 'number' ? clampInt(pressed.dueInMinutes, 0, 100_000, 0) : null };
|
|
815
|
+
loud ||= assessment.loud === true;
|
|
816
|
+
// Words said to the room but meant for someone here by name go to them; words for someone elsewhere go to the room.
|
|
817
|
+
const named = Array.isArray(assessment.addressedIds) ? [...new Set(assessment.addressedIds.filter((id) => typeof id === 'string' && present.includes(id)))] : [];
|
|
818
|
+
if (toIds.length === 0 && named.length > 0) {
|
|
819
|
+
addressed = named;
|
|
820
|
+
listeners = byTurn(session.state, player.placeId, named).slice(0, MAX_REACTORS);
|
|
821
|
+
}
|
|
822
|
+
about = Array.isArray(assessment.aboutIds) ? [...new Set(assessment.aboutIds.filter((id) => typeof id === 'string' && thingsHere.includes(id)))].slice(0, 4) : [];
|
|
823
|
+
}
|
|
824
|
+
catch (error) {
|
|
825
|
+
if (!(error instanceof LivingWorldError) || (error.code !== 'inference-invalid' && error.code !== 'inference-unavailable'))
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// The world went on while the words were weighed; only those still here hear them.
|
|
830
|
+
const stillHere = presentCharacters(session.state, player.placeId);
|
|
831
|
+
const heardBy = addressed.filter(id => stillHere.includes(id));
|
|
832
|
+
// Anyone a serious line was said to who does not answer now has it to decide later.
|
|
833
|
+
const seq = session.state.revision + 1;
|
|
834
|
+
const leftOut = importance >= SHOCK_IMPORTANCE ? heardBy.filter(id => !listeners.includes(id)) : [];
|
|
835
|
+
const decideLater = leftOut.flatMap(id => shockPlanEffects(session.state, id, generatedId(session.state.engine, 'shock', [seq, id]), `Decide what to do about what ${player.name} said to you: "${text}"`.slice(0, 600), SHOCK_PLAN_DELAY_MINUTES, player.id));
|
|
836
|
+
const event = session.commit({
|
|
837
|
+
kind: 'speech', actorId: player.id, placeId: player.placeId,
|
|
838
|
+
summary: `${player.name} says to ${heardBy.length === 0 ? 'the room' : heardBy.map(id => personLabel(session.state, id)).join(' and ')}: "${text}"`.slice(0, 2000),
|
|
839
|
+
narration: null, outcome: null, minutes: SPEECH_MINUTES,
|
|
840
|
+
effects: [{ kind: 'say', personId: player.id, text, toIds: heardBy },
|
|
841
|
+
...(promised === null ? [] : heardBy.map((id) => ({ kind: 'commitment', commitmentId: generatedId(session.state.engine, 'promise', [seq, id]), personId: player.id, toId: id,
|
|
842
|
+
text: promised.text, dueInMinutes: promised.dueInMinutes }))), ...decideLater],
|
|
843
|
+
causeSeq: null, importance, suggestions: [], ...(about.length === 0 ? {} : { thingIds: about }),
|
|
844
|
+
reach: loud ? 'nearby' : 'here', notice: loud ? `A shout comes from ${session.state.places[player.placeId].name}.` : null,
|
|
845
|
+
});
|
|
846
|
+
await react(session, listeners.filter(id => event.witnessIds.includes(id)), event, { kind: 'speech', actorId: player.id, text, narration: null, pressure }, 'reply', false);
|
|
847
|
+
if (loud)
|
|
848
|
+
await reactNearby(session, event);
|
|
849
|
+
}
|
|
850
|
+
// The player casts their own vote, seen by those present; no model reads it from their words.
|
|
851
|
+
function playerVotes(session, decisionId, choice) {
|
|
852
|
+
const player = session.player();
|
|
853
|
+
const decision = session.state.entities[decisionId];
|
|
854
|
+
if (!decision?.members?.includes(player.id))
|
|
855
|
+
throw new LivingWorldError('command-invalid', 'You have no vote on that');
|
|
856
|
+
const shut = votingShut(session.state, decision, session.state.clock);
|
|
857
|
+
if (shut)
|
|
858
|
+
throw new LivingWorldError('command-invalid', `${decision.name} ${shut}`);
|
|
859
|
+
if (decision.ballots?.[player.id] !== undefined)
|
|
860
|
+
throw new LivingWorldError('command-invalid', `You have already voted on ${decision.name}`);
|
|
861
|
+
const summary = `${player.name} votes ${choice} on ${decision.name}`;
|
|
862
|
+
session.commit({ kind: 'attempt', actorId: player.id, placeId: player.placeId, summary, narration: `You vote ${choice} on ${decision.name}.`, outcome: 'succeeded',
|
|
863
|
+
minutes: 1, effects: [{ kind: 'ballot', entityId: decision.id, personId: player.id, choice }], causeSeq: null, importance: 4, suggestions: [], ...quiet, observed: summary });
|
|
864
|
+
}
|
|
865
|
+
// Those spoken to, the one who spoke least lately first, so the same few do not always answer.
|
|
866
|
+
function byTurn(state, placeId, ids) {
|
|
867
|
+
const lines = state.conversations.filter(conversation => conversation.placeId === placeId && conversation.endedAt === null).flatMap(conversation => conversation.lines);
|
|
868
|
+
const lastSpoke = (id) => Math.max(-1, ...lines.filter(line => line.speakerId === id).map(line => line.seq));
|
|
869
|
+
return [...ids].sort((left, right) => lastSpoke(left) - lastSpoke(right) || left.localeCompare(right));
|
|
870
|
+
}
|
|
871
|
+
function playerMessages(session, rawToIds, rawText) {
|
|
872
|
+
const gag = gateAgainst(session.state, session.state.playerId, 'message');
|
|
873
|
+
if (gag)
|
|
874
|
+
throw new LivingWorldError('command-invalid', `You cannot send messages now: ${gag.name}`);
|
|
875
|
+
const text = requireText(rawText);
|
|
876
|
+
const player = session.player();
|
|
877
|
+
const toIds = [...new Set(rawToIds)];
|
|
878
|
+
if (toIds.length === 0 || toIds.length > 8)
|
|
879
|
+
throw new LivingWorldError('command-invalid', 'A message needs one to eight recipients');
|
|
880
|
+
for (const id of toIds)
|
|
881
|
+
if (!session.state.people[id] || session.state.people[id].isPlayer)
|
|
882
|
+
throw new LivingWorldError('command-invalid', `${id} cannot receive messages`);
|
|
883
|
+
// A message to someone who left still goes, as a note to one who quit would; no answer comes.
|
|
884
|
+
const readers = toIds.filter(id => session.state.people[id].departed === undefined);
|
|
885
|
+
const seq = session.state.revision + 1;
|
|
886
|
+
const travel = session.state.messageTravel;
|
|
887
|
+
session.commit({
|
|
888
|
+
kind: 'message', actorId: player.id, placeId: player.placeId,
|
|
889
|
+
summary: `${player.name} sends a message to ${toIds.map(id => personLabel(session.state, id)).join(', ')}${travel ? ` ${travel.how}` : ''}`, narration: null, outcome: null, minutes: MESSAGE_MINUTES,
|
|
890
|
+
effects: travel ? [dispatch(seq, 0, { fromId: player.id, toIds, text, replies: true })] : [
|
|
891
|
+
{ kind: 'message', fromId: player.id, toIds, text },
|
|
892
|
+
...readers.map((id) => ({ kind: 'schedule', scheduledId: generatedId(session.state.engine, 'reply', [seq, id]), personId: id, inMinutes: MESSAGE_REPLY_DELAY_MINUTES, intent: text, placeId: null, targetId: player.id, scheduleKind: 'reply' })),
|
|
893
|
+
],
|
|
894
|
+
causeSeq: null, importance: 3, suggestions: [], ...quiet,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
// Weighing the player's words names this many things, so its input stays one size however full the room.
|
|
898
|
+
const SPEECH_THINGS = 8;
|
|
899
|
+
// The things someone knows of that lie elsewhere, or nowhere, the latest changed first.
|
|
900
|
+
const KNOWN_ELSEWHERE = 8;
|
|
901
|
+
function knownElsewhere(state, personId, placeId) {
|
|
902
|
+
const lastChanged = (entity) => Math.max(entity.introducedSeq, ...Object.values(entity.properties).map(slot => slot.changedSeq));
|
|
903
|
+
return Object.values(state.entities).filter(entity => entity.placeId !== placeId && entity.knownTo.includes(personId))
|
|
904
|
+
.sort((left, right) => lastChanged(right) - lastChanged(left) || left.id.localeCompare(right.id)).slice(0, KNOWN_ELSEWHERE).map(entity => entity.id);
|
|
905
|
+
}
|
|
906
|
+
// Where the world says messages travel, a message is handed on now and arrives later.
|
|
907
|
+
function dispatch(seq, index, message) {
|
|
908
|
+
const { sourcePersonId, request, ...rest } = message;
|
|
909
|
+
return { kind: 'dispatch', dispatchId: `deliver-${seq}-${index}`, ...rest, toIds: [...rest.toIds],
|
|
910
|
+
...(sourcePersonId ? { sourcePersonId } : {}), ...(request ? { request } : {}) };
|
|
911
|
+
}
|
|
912
|
+
// A message on its way reaches those still in the world, with no model. A reader who answers
|
|
913
|
+
// does so a little after it arrives, and the answer travels back the same way.
|
|
914
|
+
function deliverMessage(session, due) {
|
|
915
|
+
const state = session.state;
|
|
916
|
+
const delivery = due.delivery;
|
|
917
|
+
const readers = delivery.toIds.filter(id => state.people[id] && !state.people[id].departed);
|
|
918
|
+
const seq = state.revision + 1;
|
|
919
|
+
const from = personLabel(state, delivery.fromId);
|
|
920
|
+
const effects = [{ kind: 'unschedule', scheduledId: due.id }];
|
|
921
|
+
if (readers.length > 0) {
|
|
922
|
+
effects.push({ kind: 'message', fromId: delivery.fromId, toIds: readers, text: delivery.text });
|
|
923
|
+
const request = delivery.request;
|
|
924
|
+
if (request && readers.length === 1)
|
|
925
|
+
effects.push({ kind: 'request.open', requestId: request.requestId, senderId: delivery.fromId, receiverId: readers[0], topic: request.topic });
|
|
926
|
+
if (delivery.replies) {
|
|
927
|
+
effects.push(...readers.filter(id => !state.people[id].isPlayer && id !== delivery.fromId).map((id) => ({ kind: 'schedule', scheduledId: generatedId(session.state.engine, 'reply', [seq, id]), personId: id,
|
|
928
|
+
inMinutes: MESSAGE_REPLY_DELAY_MINUTES, intent: session.state.engine >= FULL_SCHEDULE_INTENT_ENGINE ? delivery.text : delivery.text.slice(0, MAX_LINE), placeId: null, targetId: delivery.fromId, scheduleKind: 'reply' })));
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
session.commit({
|
|
932
|
+
kind: 'message', actorId: null, placeId: null,
|
|
933
|
+
summary: readers.length > 0 ? `A message from ${from} reaches ${readers.map(id => personLabel(state, id)).join(', ')}` : `A message from ${from} finds no one to read it`,
|
|
934
|
+
narration: null, outcome: null, minutes: 0, effects, causeSeq: due.sourceSeq || null, importance: due.importance, suggestions: [], ...quiet,
|
|
935
|
+
});
|
|
936
|
+
return Promise.resolve(true);
|
|
937
|
+
}
|
|
938
|
+
function nextDue(state) {
|
|
939
|
+
return state.schedule.find(item => item.personId === null || (!state.people[item.personId]?.incapacitated && !state.people[item.personId]?.isPlayer));
|
|
940
|
+
}
|
|
941
|
+
async function playerWaits(session) {
|
|
942
|
+
const player = session.player();
|
|
943
|
+
const next = nextDue(session.state);
|
|
944
|
+
const untilNext = next ? next.at - session.state.clock : null;
|
|
945
|
+
session.commit({
|
|
946
|
+
kind: 'wait', actorId: player.id, placeId: player.placeId, summary: untilNext !== null && untilNext > 0 ? `${player.name} waits until something happens` : `${player.name} waits a while`,
|
|
947
|
+
narration: null, outcome: null, minutes: untilNext === null ? IDLE_WAIT_MINUTES : untilNext > 0 ? Math.min(untilNext, IDLE_WAIT_MINUTES) : OVERDUE_WAIT_MINUTES, effects: [], causeSeq: null, importance: 1, suggestions: [], ...quiet,
|
|
948
|
+
});
|
|
949
|
+
if (!next)
|
|
950
|
+
await idleTick(session);
|
|
951
|
+
}
|
|
952
|
+
function keepPlaying(session) {
|
|
953
|
+
const stop = session.state.stoppingPoint;
|
|
954
|
+
if (!stop)
|
|
955
|
+
throw new LivingWorldError('command-invalid', 'There is no stopping point to pass');
|
|
956
|
+
session.commit({
|
|
957
|
+
kind: 'keep-playing', actorId: session.state.playerId, placeId: null, summary: `${session.player().name} keeps going after: ${stop.title}`.slice(0, 2000),
|
|
958
|
+
narration: null, outcome: null, minutes: 0, effects: [], causeSeq: null, importance: 1, suggestions: [], ...quiet,
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
const recapCardSchema = endingSchema.extend({ title: z.string().trim().min(1).max(200) });
|
|
962
|
+
async function finish(session) {
|
|
963
|
+
const answer = await session.complete({
|
|
964
|
+
role: 'narrator', phase: 'epilogue', system: NARRATOR_EPILOGUE_SYSTEM, input: epilogueInput(session.state), responseSchema: epilogueResponseSchema,
|
|
965
|
+
...LIVING_INFERENCE_LIMITS.epilogue,
|
|
966
|
+
}, null);
|
|
967
|
+
const card = recapCardSchema.safeParse(answer);
|
|
968
|
+
if (!card.success) {
|
|
969
|
+
session.rejectLast('epilogue-invalid');
|
|
970
|
+
throw new LivingWorldError('inference-invalid', 'The epilogue is malformed');
|
|
971
|
+
}
|
|
972
|
+
const { title, ...ending } = card.data;
|
|
973
|
+
session.commit({
|
|
974
|
+
kind: 'finish', actorId: session.state.playerId, placeId: null, summary: title, narration: ending.highlights.join(' ').slice(0, 2000),
|
|
975
|
+
outcome: null, minutes: 0, effects: [], causeSeq: null, importance: 10, suggestions: [], ending, ...quiet,
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
// Speech to the room reaches those it names first, then whoever is most involved with the player.
|
|
979
|
+
function chooseListeners(state, present) {
|
|
980
|
+
const involved = new Set(state.tensions.filter(tension => tension.status === 'open').flatMap(tension => tension.participantIds));
|
|
981
|
+
const warmth = (id) => Math.abs(state.people[id].relationships[state.playerId]?.warmth ?? 0);
|
|
982
|
+
return [...present].sort((a, b) => Number(involved.has(b)) - Number(involved.has(a)) || warmth(b) - warmth(a) || a.localeCompare(b)).slice(0, MAX_REACTORS);
|
|
983
|
+
}
|
|
984
|
+
async function adjudicate(session, actorId, attempt, finishing = null) {
|
|
985
|
+
const able = () => {
|
|
986
|
+
const actor = session.state.people[actorId];
|
|
987
|
+
if (actor.incapacitated)
|
|
988
|
+
throw new LivingWorldError('command-invalid', `${actor.name} cannot act now`);
|
|
989
|
+
return actor;
|
|
990
|
+
};
|
|
991
|
+
able();
|
|
992
|
+
// A long act whose time has passed ends as it was ruled before its actor does anything new.
|
|
993
|
+
if (finishing === null)
|
|
994
|
+
await finishDue(session, actorId);
|
|
995
|
+
const actor = able();
|
|
996
|
+
// A new act sets aside a long act of the actor's still under way, and tells them; ruling one's end clears it.
|
|
997
|
+
const clears = (state) => state.schedule.filter(item => item.kind === 'finish' && item.targetId === actorId)
|
|
998
|
+
.map(item => item.id === finishing?.item.id ? { kind: 'unschedule', scheduledId: item.id } : leaveOff(item));
|
|
999
|
+
const causeSeq = finishing ? finishing.item.sourceSeq || null : null;
|
|
1000
|
+
const verb = finishing ? 'finishes' : 'tries';
|
|
1001
|
+
const gate = gateAgainst(session.state, actorId, 'act');
|
|
1002
|
+
if (gate) {
|
|
1003
|
+
// A gate on acting settles the attempt itself; there is nothing for the referee to weigh.
|
|
1004
|
+
session.commit({
|
|
1005
|
+
kind: 'attempt', actorId, placeId: actor.placeId, summary: `${actor.name} ${verb}: "${attempt}" — failed. ${gate.name} stops ${actor.name}.`.slice(0, 2000),
|
|
1006
|
+
narration: `${gate.name} stops ${actor.name}.`, outcome: 'failed', minutes: 1, effects: clears(session.state), causeSeq, importance: 1, suggestions: [], reach: 'here', notice: null,
|
|
1007
|
+
});
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
const begun = finishing ? { begunAt: clockLabel(finishing.item.held?.at ?? finishing.item.at), changed: finishing.changed } : undefined;
|
|
1011
|
+
await resolve(session, {
|
|
1012
|
+
phase: 'attempt', actorId, placeId: actor.placeId, kind: 'attempt', causeSeq, extraEffects: [], clears, attempt, finishing: finishing !== null,
|
|
1013
|
+
...(finishing ? { finishingId: finishing.item.id } : {}),
|
|
1014
|
+
input: (errors, fitted, focusIds) => gameMasterAttemptInput(session.state, actorId, attempt, errors, fitted, begun, focusIds),
|
|
1015
|
+
summary: ruling => `${actor.name} ${verb}: "${attempt}" — ${ruling.outcome}. ${ruling.record}`,
|
|
1016
|
+
trigger: ruling => ({ kind: 'attempt', actorId, text: ruling.observed ?? `${actor.name} acts nearby.`, narration: null }),
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
// A long act set aside before its end: its item leaves the schedule, naming what it was and when it began.
|
|
1020
|
+
function leaveOff(item) {
|
|
1021
|
+
return { kind: 'unschedule', scheduledId: item.id, leftOff: { intent: item.intent, begunAt: item.held?.at ?? item.at } };
|
|
1022
|
+
}
|
|
1023
|
+
// Each long act of this actor whose time has passed ends before they do anything else.
|
|
1024
|
+
async function finishDue(session, actorId) {
|
|
1025
|
+
for (const item of session.state.schedule.filter(candidate => candidate.kind === 'finish' && candidate.targetId === actorId && candidate.at <= session.state.clock)) {
|
|
1026
|
+
if (session.state.schedule.some(candidate => candidate.id === item.id))
|
|
1027
|
+
await finishAct(session, item);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
// A long act ends when its time has passed. When no one and nothing it names has changed, its held
|
|
1031
|
+
// ruling applies as it was made; otherwise the Game Master rules its end on the world as it is. An
|
|
1032
|
+
// act whose actor left the place, or can no longer act, or whose end cannot be ruled, is left where it began.
|
|
1033
|
+
async function finishAct(session, due) {
|
|
1034
|
+
const actor = due.targetId === null ? undefined : session.state.people[due.targetId];
|
|
1035
|
+
// Its actor is told it ended unfinished, and anyone there sees them stop.
|
|
1036
|
+
const setAside = () => {
|
|
1037
|
+
const now = actor === undefined ? undefined : session.state.people[actor.id];
|
|
1038
|
+
session.commit({ kind: 'world', actorId: now?.id ?? null, placeId: now && now.departed === undefined && now.away === undefined ? now.placeId : null,
|
|
1039
|
+
summary: `${actor?.name ?? 'Someone'} leaves off: ${due.intent}`.slice(0, 2000), narration: now?.isPlayer ? 'You stop before finishing.' : null,
|
|
1040
|
+
observed: `${actor?.name ?? 'Someone'} stops before finishing.`, outcome: null, minutes: 0,
|
|
1041
|
+
effects: [leaveOff(due)], causeSeq: due.sourceSeq || null, importance: 1, suggestions: [], ...quiet });
|
|
1042
|
+
return true;
|
|
1043
|
+
};
|
|
1044
|
+
if (!actor || actor.incapacitated || actor.departed || actor.away || actor.placeId !== due.placeId || !due.held)
|
|
1045
|
+
return setAside();
|
|
1046
|
+
const held = due.held;
|
|
1047
|
+
const state = session.state;
|
|
1048
|
+
const changed = Object.entries(held.standing).filter(([id, was]) => standing(state, id) !== was).map(([id]) => nowStanding(state, id));
|
|
1049
|
+
if (changed.length === 0) {
|
|
1050
|
+
const fields = held.fields;
|
|
1051
|
+
// Whoever is still there perceives it; people who walked in meanwhile only see the end.
|
|
1052
|
+
const witnesses = witnessesFor(state, { actorId: actor.id, placeId: due.placeId });
|
|
1053
|
+
const ending = { ...fields, effects: [{ kind: 'unschedule', scheduledId: due.id }, ...fields.effects], causeSeq: due.sourceSeq || null,
|
|
1054
|
+
perceivedBy: (fields.perceivedBy ?? []).filter(id => witnesses.includes(id)) };
|
|
1055
|
+
// A serious act leaves its witnesses, as they are now, something to decide. An engine-39 held
|
|
1056
|
+
// ruling, which kept no record, already carries those consequences.
|
|
1057
|
+
if (ending.importance >= SHOCK_IMPORTANCE && ending.outcome !== 'impossible' && held.record !== undefined) {
|
|
1058
|
+
const draft = session.draft(ending);
|
|
1059
|
+
ending.effects = [...ending.effects, ...seriousConsequences(state, { actorId: actor.id, placeId: due.placeId, seq: draft.seq, minutes: 0, record: held.record ?? due.intent,
|
|
1060
|
+
observed: ending.observed ?? null, reactorIds: held.reactorIds, perceivers: ending.perceivedBy ?? [], effects: fields.effects, notice: ending.notice, nearbyIds: draft.nearbyIds })];
|
|
1061
|
+
}
|
|
1062
|
+
let event = null;
|
|
1063
|
+
try {
|
|
1064
|
+
event = session.commit(ending);
|
|
1065
|
+
}
|
|
1066
|
+
catch (error) {
|
|
1067
|
+
if (!(error instanceof LivingWorldError))
|
|
1068
|
+
throw error;
|
|
1069
|
+
}
|
|
1070
|
+
if (event) {
|
|
1071
|
+
const reactorIds = held.reactorIds.filter(id => event.perceivedBy?.includes(id) && presentCharacters(session.state, due.placeId, actor.id).includes(id));
|
|
1072
|
+
await react(session, reactorIds, event, { kind: 'attempt', actorId: actor.id, text: event.observed ?? `${actor.name} acts nearby.`, narration: null }, 'reaction', true);
|
|
1073
|
+
await reactNearby(session, event);
|
|
1074
|
+
return true;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
try {
|
|
1078
|
+
await adjudicate(session, actor.id, due.intent, { item: due, changed });
|
|
1079
|
+
}
|
|
1080
|
+
catch (error) {
|
|
1081
|
+
if (!(error instanceof LivingWorldError))
|
|
1082
|
+
throw error;
|
|
1083
|
+
if (error.code === 'inference-unavailable')
|
|
1084
|
+
return false;
|
|
1085
|
+
if (error.code !== 'inference-invalid' && error.code !== 'command-invalid')
|
|
1086
|
+
throw error;
|
|
1087
|
+
return setAside();
|
|
1088
|
+
}
|
|
1089
|
+
// The actor was struck down while it was ruled: the act ends there.
|
|
1090
|
+
return session.state.schedule.some(item => item.id === due.id) ? setAside() : true;
|
|
1091
|
+
}
|
|
1092
|
+
// The Game Master rules; the reducer checks; an unacceptable ruling goes back once with
|
|
1093
|
+
// its reasons. Then the people there react, someone nearby may notice, and a shock
|
|
1094
|
+
// leaves every witness deciding what to do about it.
|
|
1095
|
+
async function resolve(session, job) {
|
|
1096
|
+
let errors = [];
|
|
1097
|
+
let extraRepair = false;
|
|
1098
|
+
let staleRulings = 0;
|
|
1099
|
+
for (let round = 0; round < 3; round += 1) {
|
|
1100
|
+
if (round === 2 && (!extraRepair || !session.canSpend()))
|
|
1101
|
+
break;
|
|
1102
|
+
// The ruling is checked against the world the model was shown, whenever the call was built.
|
|
1103
|
+
let asked = session.state;
|
|
1104
|
+
const build = (focusIds = []) => {
|
|
1105
|
+
asked = session.state;
|
|
1106
|
+
const input = job.input(errors, true, focusIds);
|
|
1107
|
+
// The answer may name only what the model was shown.
|
|
1108
|
+
return { input, responseSchema: scopedAttemptResponseSchema(input) };
|
|
1109
|
+
};
|
|
1110
|
+
const answer = await session.complete({ role: 'game-master', phase: job.phase, system: GAME_MASTER_SYSTEM, ...build(), ...LIVING_INFERENCE_LIMITS[job.phase] }, job.actorId, null, build);
|
|
1111
|
+
const ruling = parseRuling(answer);
|
|
1112
|
+
// A proposed id already held by a saved thing outside this command's view is taken as well.
|
|
1113
|
+
const proposedIds = ruling.effects.flatMap(effect => effect.kind === 'entity.create' && typeof effect.entityId === 'string' ? [effect.entityId] : []);
|
|
1114
|
+
const usedCreationIds = new Set(proposedIds.length === 0 ? [] : await session.takenEntityIds(proposedIds));
|
|
1115
|
+
// The world goes on while the Game Master rules. A ruling is checked against the world as it is
|
|
1116
|
+
// when it returns; when a person or thing it names has changed since, it is ruled again.
|
|
1117
|
+
const state = session.state;
|
|
1118
|
+
if (job.actorId !== null && (!state.people[job.actorId] || state.people[job.actorId].incapacitated))
|
|
1119
|
+
return;
|
|
1120
|
+
if (job.finishingId !== undefined && !state.schedule.some(item => item.id === job.finishingId))
|
|
1121
|
+
return;
|
|
1122
|
+
const extra = [...job.extraEffects, ...(job.clears ? job.clears(state) : [])];
|
|
1123
|
+
const placeId = job.actorId !== null && job.kind === 'attempt' ? state.people[job.actorId].placeId : job.placeId;
|
|
1124
|
+
if (state !== asked) {
|
|
1125
|
+
const changed = changedReferences(asked, state, job.actorId, ruling);
|
|
1126
|
+
if (changed.length > 0 && staleRulings < MAX_STALE_RULINGS && session.canSpend()) {
|
|
1127
|
+
staleRulings += 1;
|
|
1128
|
+
round -= 1;
|
|
1129
|
+
session.rejectLast('ruling-stale');
|
|
1130
|
+
errors = [`While this was ruled, the scene changed: ${changed.join('; ')}. Rule on the scene as it is now.`];
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
if (job.nothingNew?.(ruling)) {
|
|
1135
|
+
session.commit({ kind: 'world', actorId: null, placeId, summary: 'Nothing new came of it', narration: null, outcome: null, minutes: 0,
|
|
1136
|
+
effects: extra, causeSeq: null, importance: 1, suggestions: [], ...quiet, perceivedBy: [] });
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
// A gate on this kind of act voids the ruling's changes: the act fails and nothing lasts.
|
|
1140
|
+
const gate = job.actorId !== null && ruling.actionKind !== null ? gateAgainst(state, job.actorId, ruling.actionKind) : null;
|
|
1141
|
+
if (gate) {
|
|
1142
|
+
ruling.outcome = 'failed';
|
|
1143
|
+
ruling.effects = [];
|
|
1144
|
+
ruling.settledTensionId = null;
|
|
1145
|
+
ruling.newTension = null;
|
|
1146
|
+
ruling.narration = `${ruling.narration} ${gate.name} stops ${state.people[job.actorId].name}.`.slice(0, 2000);
|
|
1147
|
+
}
|
|
1148
|
+
// Only the player's own act may take a long stretch of the player's time; a character's act or a world
|
|
1149
|
+
// happening resolves in a few minutes so the player's clock does not jump while they stand there.
|
|
1150
|
+
if (job.actorId !== state.playerId && !session.live()) {
|
|
1151
|
+
ruling.minutes = Math.min(ruling.minutes, OTHERS_MAX_MINUTES, Math.max(0, MAX_NONPLAYER_MINUTES_PER_COMMAND - session.nonPlayerMinutes));
|
|
1152
|
+
}
|
|
1153
|
+
const seq = state.revision + 1;
|
|
1154
|
+
const translated = ruling.effects.flatMap((effect, index, all) => {
|
|
1155
|
+
// A repeated identical change says nothing new; two unnamed creations are two new things.
|
|
1156
|
+
if (!anonymousCreation(effect) && all.slice(0, index).some(prior => isDeepStrictEqual(prior, effect)))
|
|
1157
|
+
return [];
|
|
1158
|
+
const result = translateGmEffect(state, effect, seq, index, placeId, usedCreationIds);
|
|
1159
|
+
if ('effect' in result) {
|
|
1160
|
+
const id = createdIdentity(result.effect);
|
|
1161
|
+
if (id !== null)
|
|
1162
|
+
usedCreationIds.add(id);
|
|
1163
|
+
}
|
|
1164
|
+
return [result];
|
|
1165
|
+
});
|
|
1166
|
+
const failures = translated.filter((item) => 'error' in item).map(item => item.error);
|
|
1167
|
+
if (ruling.kindFit === 'missing' && ruling.newKind === null)
|
|
1168
|
+
failures.push('stateKindFit missing needs stateNewKind with a valid id, name, and general meaning');
|
|
1169
|
+
if (ruling.kindFit !== 'missing' && ruling.newKind !== null)
|
|
1170
|
+
failures.push('stateNewKind needs stateKindFit missing');
|
|
1171
|
+
if (ruling.newKind !== null && state.kinds[ruling.newKind.id]) {
|
|
1172
|
+
failures.push(`Kind ${ruling.newKind.id} already exists; use it in entity.create or request a different missing kind`);
|
|
1173
|
+
}
|
|
1174
|
+
const witnesses = witnessesFor(state, { actorId: job.actorId, placeId: placeId });
|
|
1175
|
+
const perceivers = job.seenByAll ? witnesses : ruling.perceivedBy ?? witnesses;
|
|
1176
|
+
if (new Set(perceivers).size !== perceivers.length || perceivers.some(id => !witnesses.includes(id)))
|
|
1177
|
+
failures.push('perceivedBy must name only distinct people present at the act');
|
|
1178
|
+
// An impossible attempt never happened, so it changes nothing but how people feel about the asking.
|
|
1179
|
+
const allowedWhenImpossible = new Set(['mood', 'relationship', 'belief']);
|
|
1180
|
+
// New places and people come first, so a ruling's other changes may name them in any order.
|
|
1181
|
+
const first = (effect) => effect.kind === 'place.create' ? 0 : effect.kind === 'person.create' ? 1 : 2;
|
|
1182
|
+
const effects = translated.filter((item) => 'effect' in item).map(item => item.effect)
|
|
1183
|
+
.filter(effect => ruling.outcome !== 'impossible' || allowedWhenImpossible.has(effect.kind))
|
|
1184
|
+
.sort((left, right) => first(left) - first(right));
|
|
1185
|
+
const objectChanges = new Map();
|
|
1186
|
+
// One act takes each person to at most one neighboring place.
|
|
1187
|
+
const moved = new Set();
|
|
1188
|
+
// A change refused on its own is left out of a happening's last ruling as well.
|
|
1189
|
+
const refused = new Set();
|
|
1190
|
+
for (const effect of effects) {
|
|
1191
|
+
if (job.movesNoMoney && (effect.kind === 'transfer' || effect.kind === 'money')) {
|
|
1192
|
+
failures.push('A problem going on by itself moves no money; money moves by acts and by the world\'s own written happenings');
|
|
1193
|
+
refused.add(effect);
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
// A happening moves, sends away or removes no one who was already here: people go by their own decisions.
|
|
1197
|
+
if (job.kind === 'world' && (effect.kind === 'move' || effect.kind === 'person.away' || effect.kind === 'person.leave') && state.people[effect.personId]) {
|
|
1198
|
+
failures.push(`A happening moves no one; ${state.people[effect.personId].name} goes where they decide to go`);
|
|
1199
|
+
refused.add(effect);
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
if (effect.kind === 'move' && state.people[effect.personId]?.placeId === effect.placeId) {
|
|
1203
|
+
failures.push(`Person ${effect.personId} is already at ${effect.placeId}`);
|
|
1204
|
+
}
|
|
1205
|
+
if (effect.kind === 'move' && state.people[effect.personId]
|
|
1206
|
+
&& !state.places[state.people[effect.personId].placeId].connections.includes(effect.placeId)) {
|
|
1207
|
+
failures.push(`Person ${effect.personId} can only go to a neighboring place in one act`);
|
|
1208
|
+
}
|
|
1209
|
+
if (effect.kind === 'move' && moved.has(effect.personId))
|
|
1210
|
+
failures.push(`Person ${effect.personId} moves more than once in one act`);
|
|
1211
|
+
if (effect.kind === 'move')
|
|
1212
|
+
moved.add(effect.personId);
|
|
1213
|
+
if (effect.kind === 'entity.create' && effect.placeId !== null && effect.placeId !== placeId) {
|
|
1214
|
+
failures.push(`A new ${effect.kindId} must be created at ${placeId}, where this act happens, or belong to no place`);
|
|
1215
|
+
}
|
|
1216
|
+
if (effect.kind === 'object.update') {
|
|
1217
|
+
const previous = objectChanges.get(effect.objectId) ?? state.objects[effect.objectId];
|
|
1218
|
+
if (!previous)
|
|
1219
|
+
continue;
|
|
1220
|
+
const next = { placeId: previous.placeId, holderId: previous.holderId,
|
|
1221
|
+
ownerId: previous.ownerId, condition: previous.condition };
|
|
1222
|
+
if (effect.placeId !== null) {
|
|
1223
|
+
next.placeId = effect.placeId;
|
|
1224
|
+
next.holderId = null;
|
|
1225
|
+
}
|
|
1226
|
+
if (effect.holderId !== null) {
|
|
1227
|
+
next.holderId = effect.holderId;
|
|
1228
|
+
next.placeId = null;
|
|
1229
|
+
}
|
|
1230
|
+
if (effect.ownerId !== null)
|
|
1231
|
+
next.ownerId = effect.ownerId;
|
|
1232
|
+
if (effect.condition !== null)
|
|
1233
|
+
next.condition = effect.condition;
|
|
1234
|
+
objectChanges.set(effect.objectId, next);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
// A ruling changes only what is at hand where it happens, and what a happening concerns.
|
|
1238
|
+
const heldHere = (holderId) => holderId !== null && state.people[holderId]?.placeId === placeId;
|
|
1239
|
+
const where = (thing) => thing.holderId !== null && state.people[thing.holderId]
|
|
1240
|
+
? `with ${state.people[thing.holderId].name} in ${state.places[state.people[thing.holderId].placeId]?.name ?? 'another place'}` : `in ${state.places[thing.placeId ?? '']?.name ?? 'another place'}`;
|
|
1241
|
+
const atHand = () => [...Object.values(state.objects).filter(object => object.placeId === placeId || heldHere(object.holderId)).map(object => object.name),
|
|
1242
|
+
...(state.entityPlaceIds[placeId] ?? []).map(id => state.entities[id].name)];
|
|
1243
|
+
const elsewhere = (thing) => `${thing.name} is ${where(thing)}, not in ${state.places[placeId].name}, where this happens; things at hand: ${atHand().join(', ') || 'none'}`;
|
|
1244
|
+
for (const effect of effects) {
|
|
1245
|
+
if (effect.kind === 'object.update') {
|
|
1246
|
+
const object = state.objects[effect.objectId];
|
|
1247
|
+
if (object && object.id !== job.concerns && object.placeId !== placeId && !heldHere(object.holderId)) {
|
|
1248
|
+
failures.push(elsewhere(object));
|
|
1249
|
+
refused.add(effect);
|
|
1250
|
+
}
|
|
1251
|
+
// A thing may be put down here, or thrown or pushed next door, but not sent further.
|
|
1252
|
+
else if (effect.placeId !== null && effect.placeId !== placeId && !state.places[placeId].connections.includes(effect.placeId)) {
|
|
1253
|
+
failures.push(`${object?.name ?? effect.objectId} can only go to ${state.places[placeId].name}, where this happens, or next door`);
|
|
1254
|
+
refused.add(effect);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
const entityId = effect.kind === 'entity.condition' || effect.kind === 'property.set' || effect.kind === 'relation.set' || effect.kind === 'entity.move' ? effect.entityId : null;
|
|
1258
|
+
const entity = entityId === null ? undefined : state.entities[entityId];
|
|
1259
|
+
if (entity && entity.id !== job.concerns && entity.placeId !== null && entity.placeId !== placeId) {
|
|
1260
|
+
failures.push(elsewhere(entity));
|
|
1261
|
+
refused.add(effect);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
// A change that restates a thing as it already is changes nothing: it is left out, not refused.
|
|
1265
|
+
const restated = new Set();
|
|
1266
|
+
for (const [id, changed] of objectChanges) {
|
|
1267
|
+
const original = state.objects[id];
|
|
1268
|
+
if (changed.placeId === original.placeId && changed.holderId === original.holderId
|
|
1269
|
+
&& changed.ownerId === original.ownerId && changed.condition === original.condition) {
|
|
1270
|
+
for (const effect of effects)
|
|
1271
|
+
if (effect.kind === 'object.update' && effect.objectId === id)
|
|
1272
|
+
restated.add(effect);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
for (const effect of effects) {
|
|
1276
|
+
if (effect.kind === 'property.set' && state.entities[effect.entityId]?.properties[effect.propertyId]?.value === effect.value)
|
|
1277
|
+
restated.add(effect);
|
|
1278
|
+
if (effect.kind === 'relation.set' && state.entities[effect.entityId]?.relations[effect.relationId]?.targetId === effect.targetId)
|
|
1279
|
+
restated.add(effect);
|
|
1280
|
+
}
|
|
1281
|
+
for (let index = effects.length - 1; index >= 0; index -= 1)
|
|
1282
|
+
if (restated.has(effects[index]))
|
|
1283
|
+
effects.splice(index, 1);
|
|
1284
|
+
// Only the player's own act writes or signs a paper in the player's name; restating one as it already is writes nothing.
|
|
1285
|
+
if (job.actorId !== state.playerId) {
|
|
1286
|
+
const player = state.people[state.playerId].name;
|
|
1287
|
+
const writer = job.actorId === null ? 'A happening' : state.people[job.actorId].name;
|
|
1288
|
+
for (const effect of effects) {
|
|
1289
|
+
const inPlayerName = effect.kind === 'entity.create' ? effect.initialProperties?.author === state.playerId
|
|
1290
|
+
: effect.kind === 'property.set' && (effect.propertyId === 'author' && effect.value === state.playerId
|
|
1291
|
+
|| effect.propertyId === 'signed' && effect.value === true && state.entities[effect.entityId]?.properties.author?.value === state.playerId);
|
|
1292
|
+
if (!inPlayerName)
|
|
1293
|
+
continue;
|
|
1294
|
+
failures.push(`${writer} writes or signs nothing in ${player}'s name; only ${player}'s own act does. Hand on a paper that already exists, or name its real writer`);
|
|
1295
|
+
refused.add(effect);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
const objectiveEffectKinds = new Set([
|
|
1299
|
+
'entity.create', 'property.set', 'relation.set', 'move', 'take', 'drop', 'object.update', 'entity.move', 'entity.condition',
|
|
1300
|
+
'place.create', 'person.create', 'money', 'transfer', 'hold', 'role', 'incapacitate', 'request.open',
|
|
1301
|
+
]);
|
|
1302
|
+
for (const effect of effects)
|
|
1303
|
+
if (effect.kind === 'request.open' && effect.senderId !== job.actorId) {
|
|
1304
|
+
failures.push('request sender must be the actor');
|
|
1305
|
+
}
|
|
1306
|
+
if (ruling.kindFit === 'complete' && (ruling.outcome === 'succeeded' || ruling.outcome === 'partial')
|
|
1307
|
+
&& !effects.some(effect => objectiveEffectKinds.has(effect.kind)) && restated.size === 0
|
|
1308
|
+
&& ruling.settledTensionId === null && ruling.newTension === null) {
|
|
1309
|
+
failures.push('stateKindFit complete needs an objective typed change, such as entity.create or property.set; if nothing lasting changed, answer not-needed');
|
|
1310
|
+
}
|
|
1311
|
+
if (ruling.newKind !== null && !state.kinds[ruling.newKind.id]) {
|
|
1312
|
+
effects.push({ kind: 'kind.request', request: ruling.newKind });
|
|
1313
|
+
}
|
|
1314
|
+
const normViolations = job.actorId !== null && ruling.actionKind !== null && ruling.outcome !== 'impossible'
|
|
1315
|
+
? normViolationsFor(state, job.actorId, placeId, ruling.actionKind) : [];
|
|
1316
|
+
effects.push(...normViolations);
|
|
1317
|
+
const importance = Math.max(ruling.importance, importanceFloorForEffects(state, effects), normViolations.length > 0 ? SHOCK_IMPORTANCE : 1);
|
|
1318
|
+
if (ruling.settledTensionId !== null) {
|
|
1319
|
+
const tension = state.tensions.find(candidate => candidate.id === ruling.settledTensionId && candidate.status === 'open');
|
|
1320
|
+
if (!tension)
|
|
1321
|
+
failures.push(`settledTensionId ${ruling.settledTensionId} is not an open tension`);
|
|
1322
|
+
else
|
|
1323
|
+
effects.push({ kind: 'tension.update', tensionId: tension.id, status: 'settled', note: (ruling.settlement ?? ruling.narration).slice(0, 2000) });
|
|
1324
|
+
}
|
|
1325
|
+
// A change the world's own data makes is made once, however the ruling restates it.
|
|
1326
|
+
if (job.rulingEffects) {
|
|
1327
|
+
const ruled = job.rulingEffects(state, ruling);
|
|
1328
|
+
for (let index = effects.length - 1; index >= 0; index -= 1)
|
|
1329
|
+
if (ruled.some(own => isDeepStrictEqual(own, effects[index])))
|
|
1330
|
+
effects.splice(index, 1);
|
|
1331
|
+
effects.push(...ruled);
|
|
1332
|
+
}
|
|
1333
|
+
if (ruling.newTension !== null) {
|
|
1334
|
+
const tensionId = `tension-${seq}`;
|
|
1335
|
+
effects.push({ kind: 'tension.create', tensionId, title: ruling.newTension.title.slice(0, 200), summary: ruling.newTension.summary.slice(0, 2000), participantIds: ruling.newTension.participantIds.filter(id => state.people[id]),
|
|
1336
|
+
openedBy: ruling.record.slice(0, MAX_LINE) });
|
|
1337
|
+
// The model cannot name an id the engine has not minted yet, so its flag stands in for one.
|
|
1338
|
+
if (ruling.newTension.visible && !ruling.visibleTensionIds.includes(tensionId))
|
|
1339
|
+
ruling.visibleTensionIds.push(tensionId);
|
|
1340
|
+
}
|
|
1341
|
+
const sensory = signalNotice(state, effects, ruling.observed) ?? (ruling.signalChannel === null ? null
|
|
1342
|
+
: `${SIGNAL_NOTICES[ruling.signalChannel]} from ${state.places[placeId].name}.`);
|
|
1343
|
+
const affectedPlaces = new Set(effects.flatMap(effect => effect.kind === 'place.condition' ? [effect.placeId] : []));
|
|
1344
|
+
const reach = job.reach ? job.reach.reach : ruling.reach === 'here' || sensory === null ? 'here'
|
|
1345
|
+
: ruling.reach === 'everywhere' && affectedPlaces.size > 1 ? 'everywhere' : 'nearby';
|
|
1346
|
+
const notice = job.reach ? job.reach.notice ?? sensory ?? `Something happens at ${state.places[placeId].name}.` : reach === 'here' ? null : sensory;
|
|
1347
|
+
const known = (ids) => ids.filter(id => state.entities[id] !== undefined || state.objects[id] !== undefined);
|
|
1348
|
+
const draft = session.draft({
|
|
1349
|
+
kind: job.kind, actorId: job.actorId, placeId: placeId,
|
|
1350
|
+
summary: job.summary(ruling).slice(0, 2000),
|
|
1351
|
+
observed: ruling.observed ?? `${job.actorId ? state.people[job.actorId].name : 'Something'} acts in ${state.places[placeId].name}.`,
|
|
1352
|
+
perceivedBy: perceivers,
|
|
1353
|
+
visibleObjectIds: ruling.visibleObjectIds,
|
|
1354
|
+
visibleTensionIds: ruling.visibleTensionIds,
|
|
1355
|
+
...(state.engine >= THING_MEMORY_ENGINE && known(ruling.thingIds).length > 0 ? { thingIds: known(ruling.thingIds) } : {}),
|
|
1356
|
+
narration: ruling.narration, outcome: ruling.outcome, minutes: ruling.minutes, effects: [...extra, ...effects], causeSeq: job.causeSeq, importance, suggestions: [],
|
|
1357
|
+
reach, notice,
|
|
1358
|
+
});
|
|
1359
|
+
if (job.kind === 'attempt' && importance >= SHOCK_IMPORTANCE && ruling.outcome !== 'impossible') {
|
|
1360
|
+
draft.effects = [...draft.effects, ...seriousConsequences(state, { actorId: job.actorId, placeId, seq, minutes: ruling.minutes, record: ruling.record,
|
|
1361
|
+
observed: ruling.observed, reactorIds: ruling.reactorIds, perceivers, effects, notice, nearbyIds: draft.nearbyIds })];
|
|
1362
|
+
}
|
|
1363
|
+
const repeated = job.repeats?.(ruling);
|
|
1364
|
+
if (repeated)
|
|
1365
|
+
failures.push(repeated);
|
|
1366
|
+
if (failures.length === 0) {
|
|
1367
|
+
try {
|
|
1368
|
+
const proposed = applyLivingEvent(state, draft);
|
|
1369
|
+
for (const effect of effects) {
|
|
1370
|
+
if (effect.kind !== 'entity.create')
|
|
1371
|
+
continue;
|
|
1372
|
+
const entity = proposed.entities[effect.entityId];
|
|
1373
|
+
const missing = proposed.kinds[entity.kindId].properties
|
|
1374
|
+
.filter(property => property.requiredOnCreate && !entity.properties[property.id])
|
|
1375
|
+
.map(property => property.id);
|
|
1376
|
+
if (missing.length > 0)
|
|
1377
|
+
failures.push(`New ${entity.kindId} needs ${missing.join(', ')} before this act ends`);
|
|
1378
|
+
}
|
|
1379
|
+
for (const effect of draft.effects) {
|
|
1380
|
+
if (effect.kind !== 'incapacitate')
|
|
1381
|
+
continue;
|
|
1382
|
+
if (!blockedByGate(proposed, effect.personId, 'incapacitate')
|
|
1383
|
+
|| blockedByGate(state, effect.personId, 'incapacitate')) {
|
|
1384
|
+
failures.push('incapacitate needs a newly active typed gate on that person');
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
catch (error) {
|
|
1389
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
let dropped = 0;
|
|
1393
|
+
if (failures.length > 0) {
|
|
1394
|
+
session.rejectLast('effects-invalid');
|
|
1395
|
+
extraRepair = job.kind === 'attempt' && errors.length > 0 && !isDeepStrictEqual(errors, failures);
|
|
1396
|
+
const retrying = round === 0 || (round === 1 && extraRepair && session.canSpend());
|
|
1397
|
+
// A happening the world wrote happens even when some of its changes cannot be made: its last
|
|
1398
|
+
// ruling keeps what the world accepts, and counts what it leaves out.
|
|
1399
|
+
const kept = job.kind === 'world' && !retrying ? acceptedPart(state, draft, extra, effects.filter(effect => effect.kind !== 'kind.request' && !refused.has(effect))) : null;
|
|
1400
|
+
if (kept === null) {
|
|
1401
|
+
// Each repair hears every mistake so far, so fixing one does not bring back another.
|
|
1402
|
+
errors = [...new Set([...errors, ...failures])];
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
dropped = ruling.effects.length - kept.length;
|
|
1406
|
+
draft.effects = [...extra, ...kept];
|
|
1407
|
+
draft.perceivedBy = [...new Set(perceivers)].filter(id => witnesses.includes(id));
|
|
1408
|
+
if (dropped > 0)
|
|
1409
|
+
draft.droppedEffects = dropped;
|
|
1410
|
+
}
|
|
1411
|
+
// In a live world a long act only begins now. Its ruling is held, and what it tells and changes
|
|
1412
|
+
// applies when its time has passed; those present see it begin.
|
|
1413
|
+
if (job.attempt !== undefined && !job.finishing && job.actorId !== null && session.live() && ruling.minutes > LONG_ACT_MINUTES
|
|
1414
|
+
&& (ruling.outcome === 'succeeded' || ruling.outcome === 'partial')) {
|
|
1415
|
+
const actor = state.people[job.actorId];
|
|
1416
|
+
const { seq: _seq, at: _at, witnessIds: _witnesses, nearbyIds: _nearby, ...fields } = draft;
|
|
1417
|
+
// What the act leaves for others to decide is worked out when it ends, from who is there then.
|
|
1418
|
+
const held = { fields: { ...fields, effects }, record: ruling.record,
|
|
1419
|
+
standing: Object.fromEntries(referencedIds(job.actorId, ruling).filter(id => state.people[id] || state.objects[id] || state.entities[id])
|
|
1420
|
+
.map(id => [id, standing(state, id)])), reactorIds: ruling.reactorIds.filter(id => state.people[id]) };
|
|
1421
|
+
session.commit({
|
|
1422
|
+
kind: 'attempt', actorId: job.actorId, placeId, summary: `${actor.name} begins: "${job.attempt}"`.slice(0, 2000),
|
|
1423
|
+
narration: actor.isPlayer ? `You begin: ${job.attempt}`.slice(0, 2000) : null, observed: draft.observed, perceivedBy: perceivers, visibleObjectIds: [], visibleTensionIds: [],
|
|
1424
|
+
outcome: null, minutes: ruling.minutes, causeSeq: job.causeSeq, importance: 2, suggestions: [], reach: 'here', notice: null,
|
|
1425
|
+
effects: [...extra, { kind: 'schedule', scheduledId: `finish-${seq}`, personId: null, inMinutes: ruling.minutes, intent: state.engine >= FULL_SCHEDULE_INTENT_ENGINE ? job.attempt : job.attempt.slice(0, MAX_LINE),
|
|
1426
|
+
placeId, targetId: job.actorId, scheduleKind: 'finish', held }],
|
|
1427
|
+
});
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
const event = session.commit(draft);
|
|
1431
|
+
const trigger = job.trigger(ruling);
|
|
1432
|
+
const reactorIds = ruling.reactorIds.filter(id => perceivers.includes(id) && presentCharacters(session.state, placeId, job.actorId).includes(id)).slice(0, MAX_REACTORS);
|
|
1433
|
+
await react(session, reactorIds, event, trigger, 'reaction', true);
|
|
1434
|
+
await reactNearby(session, event);
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
throw new LivingWorldError('inference-invalid', `The Game Master proposed changes the world cannot accept: ${errors.join('; ')}`);
|
|
1438
|
+
}
|
|
1439
|
+
// The changes of a ruling the world accepts, one at a time in order: each must apply after those kept
|
|
1440
|
+
// before it and change something. Null when even the ruling's own bookkeeping does not apply.
|
|
1441
|
+
function acceptedPart(state, draft, extra, effects) {
|
|
1442
|
+
const applies = (list) => {
|
|
1443
|
+
try {
|
|
1444
|
+
return applyLivingEvent(state, { ...draft, effects: [...extra, ...list] });
|
|
1445
|
+
}
|
|
1446
|
+
catch (error) {
|
|
1447
|
+
if (error instanceof LivingWorldError)
|
|
1448
|
+
return null;
|
|
1449
|
+
throw error;
|
|
1450
|
+
}
|
|
1451
|
+
};
|
|
1452
|
+
if (applies([]) === null)
|
|
1453
|
+
return null;
|
|
1454
|
+
const kept = [];
|
|
1455
|
+
for (const effect of effects) {
|
|
1456
|
+
if (effect.kind === 'property.set' && state.entities[effect.entityId]?.properties[effect.propertyId]?.value === effect.value)
|
|
1457
|
+
continue;
|
|
1458
|
+
if (effect.kind === 'relation.set' && state.entities[effect.entityId]?.relations[effect.relationId]?.targetId === effect.targetId)
|
|
1459
|
+
continue;
|
|
1460
|
+
const next = applies([...kept, effect]);
|
|
1461
|
+
if (next === null)
|
|
1462
|
+
continue;
|
|
1463
|
+
if (effect.kind === 'entity.create' && next.kinds[next.entities[effect.entityId].kindId].properties
|
|
1464
|
+
.some(property => property.requiredOnCreate && !next.entities[effect.entityId].properties[property.id]))
|
|
1465
|
+
continue;
|
|
1466
|
+
kept.push(effect);
|
|
1467
|
+
}
|
|
1468
|
+
return kept;
|
|
1469
|
+
}
|
|
1470
|
+
// Where a person or thing is and what state it is in, as far as a ruling about it depends on it.
|
|
1471
|
+
function standing(state, id) {
|
|
1472
|
+
const person = state.people[id];
|
|
1473
|
+
if (person)
|
|
1474
|
+
return `person ${person.placeId} ${person.incapacitated} ${person.held?.byId ?? ''} ${person.held ? 'held' : ''}`;
|
|
1475
|
+
const object = state.objects[id];
|
|
1476
|
+
if (object)
|
|
1477
|
+
return `object ${object.placeId} ${object.holderId} ${object.condition}`;
|
|
1478
|
+
const entity = state.entities[id];
|
|
1479
|
+
if (entity)
|
|
1480
|
+
return `entity ${entity.placeId} ${entity.holderId ?? ''} ${entity.condition ?? ''} ${Math.max(entity.introducedSeq, ...Object.values(entity.properties).map(slot => slot.changedSeq), ...Object.values(entity.relations).map(slot => slot.changedSeq))}`;
|
|
1481
|
+
return 'absent';
|
|
1482
|
+
}
|
|
1483
|
+
// What a serious act leaves behind: a later look at its consequences unless the ruling already set
|
|
1484
|
+
// one, and a decision for each witness and for those who noticed it nearby, a few minutes apart.
|
|
1485
|
+
function seriousConsequences(state, act) {
|
|
1486
|
+
const out = [];
|
|
1487
|
+
const worldFollowups = act.effects.filter((effect) => effect.kind === 'schedule' && effect.personId === null);
|
|
1488
|
+
if (!worldFollowups.some(effect => effect.inMinutes >= act.minutes + 30)) {
|
|
1489
|
+
const later = worldFollowups.length > 0;
|
|
1490
|
+
out.push({ kind: 'schedule', scheduledId: `aftermath-${act.seq}`, personId: null, inMinutes: act.minutes + (later ? 45 : 15),
|
|
1491
|
+
intent: `${later ? 'Check lasting consequences' : 'Check what has changed'} after this serious event: ${act.record}`.slice(0, 600),
|
|
1492
|
+
placeId: act.placeId, targetId: null, scheduleKind: 'world' });
|
|
1493
|
+
}
|
|
1494
|
+
const present = presentCharacters(state, act.placeId, act.actorId).filter(id => act.perceivers.includes(id));
|
|
1495
|
+
const witnesses = [...new Set([...act.reactorIds, ...present])].filter(id => present.includes(id)).slice(0, MAX_REACTORS);
|
|
1496
|
+
witnesses.forEach((id, index) => {
|
|
1497
|
+
out.push(...shockPlanEffects(state, id, generatedId(state.engine, 'shock', [act.seq, id]), `Decide what to do about what you observed: ${act.observed}`.slice(0, 600), act.minutes + SHOCK_PLAN_DELAY_MINUTES + 5 * index, act.actorId));
|
|
1498
|
+
});
|
|
1499
|
+
if (act.notice !== null) {
|
|
1500
|
+
out.push(...act.nearbyIds.filter(id => state.people[id] && !state.people[id].isPlayer && !state.people[id].incapacitated && !state.people[id].away && !state.people[id].departed).slice(0, MAX_REACTORS)
|
|
1501
|
+
.flatMap((id, index) => shockPlanEffects(state, id, generatedId(state.engine, 'nearby-shock', [act.seq, id]), `Decide what to do about what you noticed: ${act.notice}`.slice(0, 600), act.minutes + SHOCK_PLAN_DELAY_MINUTES + 5 * index, null)));
|
|
1502
|
+
}
|
|
1503
|
+
return out;
|
|
1504
|
+
}
|
|
1505
|
+
// The people and things a ruling names: its actor, who perceives it or reacts, and what it changes.
|
|
1506
|
+
function referencedIds(actorId, ruling) {
|
|
1507
|
+
const ids = new Set([...(actorId === null ? [] : [actorId]), ...(ruling.perceivedBy ?? []), ...ruling.reactorIds, ...ruling.visibleObjectIds]);
|
|
1508
|
+
for (const effect of ruling.effects) {
|
|
1509
|
+
for (const value of [effect.personId, effect.targetId, effect.objectId, effect.entityId])
|
|
1510
|
+
if (typeof value === 'string')
|
|
1511
|
+
ids.add(value);
|
|
1512
|
+
}
|
|
1513
|
+
return [...ids].sort();
|
|
1514
|
+
}
|
|
1515
|
+
// How a person or thing a held act names stands now, described for the Game Master.
|
|
1516
|
+
function nowStanding(state, id) {
|
|
1517
|
+
const person = state.people[id];
|
|
1518
|
+
const object = state.objects[id];
|
|
1519
|
+
if (person)
|
|
1520
|
+
return person.incapacitated ? `${person.name} can no longer act` : `${person.name} is now at ${state.places[person.placeId]?.name ?? person.placeId}`;
|
|
1521
|
+
if (object)
|
|
1522
|
+
return `${object.name} is now ${object.holderId ? `held by ${personLabel(state, object.holderId)}` : `at ${state.places[object.placeId ?? '']?.name ?? 'an unknown place'}`}, ${object.condition}`;
|
|
1523
|
+
return `${state.entities[id]?.name ?? id} has changed`;
|
|
1524
|
+
}
|
|
1525
|
+
// The people and things a ruling names whose place or state changed between two states, described for the Game Master.
|
|
1526
|
+
function changedReferences(before, after, actorId, ruling) {
|
|
1527
|
+
const changed = [];
|
|
1528
|
+
for (const id of referencedIds(actorId, ruling)) {
|
|
1529
|
+
if (standing(before, id) === standing(after, id))
|
|
1530
|
+
continue;
|
|
1531
|
+
const person = after.people[id];
|
|
1532
|
+
const object = after.objects[id];
|
|
1533
|
+
if (person)
|
|
1534
|
+
changed.push(person.incapacitated && !before.people[id]?.incapacitated ? `${person.name} can no longer act`
|
|
1535
|
+
: `${person.name} is now at ${after.places[person.placeId]?.name ?? person.placeId}`);
|
|
1536
|
+
else if (object)
|
|
1537
|
+
changed.push(`${object.name} is now ${object.holderId ? `held by ${personLabel(after, object.holderId)}` : `at ${after.places[object.placeId ?? '']?.name ?? 'an unknown place'}`}, ${object.condition}`);
|
|
1538
|
+
else
|
|
1539
|
+
changed.push(`${after.entities[id]?.name ?? id} has changed`);
|
|
1540
|
+
}
|
|
1541
|
+
return changed;
|
|
1542
|
+
}
|
|
1543
|
+
function anonymousCreation(effect) {
|
|
1544
|
+
switch (effect.kind) {
|
|
1545
|
+
case 'entity.create': return effect.entityId === null || effect.entityId === undefined;
|
|
1546
|
+
case 'object.create': return effect.objectId === null;
|
|
1547
|
+
case 'place.create': return effect.placeId === null;
|
|
1548
|
+
case 'person.create': return effect.personId === null;
|
|
1549
|
+
case 'tension.create': return effect.tensionId === null;
|
|
1550
|
+
default: return false;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
function createdIdentity(effect) {
|
|
1554
|
+
switch (effect.kind) {
|
|
1555
|
+
case 'entity.create': return effect.entityId;
|
|
1556
|
+
case 'object.create': return effect.objectId;
|
|
1557
|
+
case 'place.create': return effect.placeId;
|
|
1558
|
+
case 'person.create': return effect.personId;
|
|
1559
|
+
case 'tension.create': return effect.tensionId;
|
|
1560
|
+
default: return null;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
// Someone in a connected room who noticed the event may come to look, call out, or carry on.
|
|
1564
|
+
async function reactNearby(session, event) {
|
|
1565
|
+
if (event.nearbyIds.length === 0 || !session.canSpendOptional())
|
|
1566
|
+
return;
|
|
1567
|
+
const state = session.state;
|
|
1568
|
+
const candidates = event.nearbyIds.filter(id => state.people[id] && !state.people[id].isPlayer && !state.people[id].incapacitated);
|
|
1569
|
+
if (candidates.length === 0)
|
|
1570
|
+
return;
|
|
1571
|
+
const closeness = (id) => {
|
|
1572
|
+
const relationship = event.actorId ? state.people[id].relationships[event.actorId] : undefined;
|
|
1573
|
+
return relationship ? Math.abs(relationship.trust) + Math.abs(relationship.warmth) : 0;
|
|
1574
|
+
};
|
|
1575
|
+
// Someone the event brings into a person's room is seen arriving; that person does not hear a commotion.
|
|
1576
|
+
const arriving = candidates.filter(id => arrivalFor(state, event, id) !== null);
|
|
1577
|
+
const [chosen] = [...(arriving.length > 0 ? arriving : candidates)].sort((a, b) => closeness(b) - closeness(a) || a.localeCompare(b));
|
|
1578
|
+
const arrival = arrivalFor(state, event, chosen);
|
|
1579
|
+
const mover = event.effects.find(effect => effect.kind === 'move' && effect.placeId === state.people[chosen].placeId && effect.personId !== chosen);
|
|
1580
|
+
if (arrival !== null && mover?.kind === 'move') {
|
|
1581
|
+
await react(session, [chosen], event, { kind: 'arrive', actorId: mover.personId, text: `${arrival}.`, narration: null }, 'reaction', true);
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
const from = event.placeId ? state.places[event.placeId]?.name ?? null : null;
|
|
1585
|
+
await react(session, [chosen], event, { kind: 'nearby', actorId: null, text: event.notice ?? 'a commotion', narration: null, noticedFrom: from }, 'reaction', true);
|
|
1586
|
+
}
|
|
1587
|
+
function uniqueIds(values) {
|
|
1588
|
+
return [...new Set(values.filter((id) => typeof id === 'string'))];
|
|
1589
|
+
}
|
|
1590
|
+
function parseRuling(answer) {
|
|
1591
|
+
const value = answer;
|
|
1592
|
+
const outcomes = ['succeeded', 'partial', 'failed', 'impossible'];
|
|
1593
|
+
if (!value || typeof value !== 'object' || typeof value.narration !== 'string' || value.narration.trim() === '' || typeof value.outcome !== 'string' || !outcomes.includes(value.outcome) || !Array.isArray(value.stateUpdates ?? value.effects)) {
|
|
1594
|
+
throw new LivingWorldError('inference-invalid', 'The Game Master answer is malformed');
|
|
1595
|
+
}
|
|
1596
|
+
// Answers recorded before engine 25 name the same fields effects, kindFit and newKind.
|
|
1597
|
+
const updates = (value.stateUpdates ?? value.effects);
|
|
1598
|
+
const newKind = value.stateNewKind === undefined ? value.newKind : value.stateNewKind;
|
|
1599
|
+
const kindFit = value.stateKindFit ?? value.kindFit;
|
|
1600
|
+
const newTension = value.newTension && typeof value.newTension === 'object' ? value.newTension : null;
|
|
1601
|
+
const narration = value.narration.trim().slice(0, 900);
|
|
1602
|
+
return {
|
|
1603
|
+
narration,
|
|
1604
|
+
record: (typeof value.record === 'string' && value.record.trim() !== '' ? value.record.trim() : narration).slice(0, 600),
|
|
1605
|
+
observed: typeof value.observed === 'string' && value.observed.trim() !== '' ? value.observed.trim().slice(0, 600) : null,
|
|
1606
|
+
// Naming the same person or thing twice in these lists says nothing more, so each is kept once.
|
|
1607
|
+
perceivedBy: Array.isArray(value.perceivedBy) ? uniqueIds(value.perceivedBy) : null,
|
|
1608
|
+
visibleObjectIds: Array.isArray(value.visibleObjectIds) ? uniqueIds(value.visibleObjectIds) : [],
|
|
1609
|
+
// Ids of things here; the list names no enum, so as not to grow with the room, and unknown ids are dropped.
|
|
1610
|
+
thingIds: Array.isArray(value.thingIds) ? uniqueIds(value.thingIds).slice(0, 8) : [],
|
|
1611
|
+
visibleTensionIds: Array.isArray(value.visibleTensionIds) ? uniqueIds(value.visibleTensionIds) : [],
|
|
1612
|
+
outcome: value.outcome,
|
|
1613
|
+
minutes: clampInt(value.minutes, 0, 240, 5),
|
|
1614
|
+
importance: clampInt(value.importance, 1, 10, 4),
|
|
1615
|
+
reach: value.reach === 'nearby' || value.reach === 'everywhere' ? value.reach : 'here',
|
|
1616
|
+
signalChannel: typeof value.signalChannel === 'string' && SIGNAL_SENSES.includes(value.signalChannel) ? value.signalChannel : null,
|
|
1617
|
+
effects: updates.map(flattenShape).filter((effect) => effect !== null && typeof effect === 'object' && typeof effect.kind === 'string').slice(0, 8)
|
|
1618
|
+
.map(normalizeGmEffect),
|
|
1619
|
+
reactorIds: Array.isArray(value.reactorIds) ? value.reactorIds.filter((id) => typeof id === 'string') : [],
|
|
1620
|
+
settledTensionId: typeof value.settledTensionId === 'string' ? value.settledTensionId : null,
|
|
1621
|
+
settlement: typeof value.settlement === 'string' ? value.settlement : null,
|
|
1622
|
+
newTension: newTension && typeof newTension.title === 'string' && typeof newTension.summary === 'string'
|
|
1623
|
+
? { title: newTension.title, summary: newTension.summary, participantIds: Array.isArray(newTension.participantIds) ? newTension.participantIds.filter((id) => typeof id === 'string') : [], visible: newTension.visible === true }
|
|
1624
|
+
: null,
|
|
1625
|
+
newKind: kindRequestSchema.safeParse(newKind).success ? kindRequestSchema.parse(newKind) : null,
|
|
1626
|
+
kindFit: kindFit === 'complete' || kindFit === 'missing' ? kindFit : 'not-needed',
|
|
1627
|
+
actionKind: typeof value.actionKind === 'string' && /^[a-z0-9][a-z0-9-]{0,63}$/u.test(value.actionKind) ? value.actionKind : null,
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
// A change arrives as { kind, values }; answers recorded before engine 26 are already flat.
|
|
1631
|
+
function flattenShape(change) {
|
|
1632
|
+
if (change === null || typeof change !== 'object' || Array.isArray(change))
|
|
1633
|
+
return change;
|
|
1634
|
+
const { values, ...rest } = change;
|
|
1635
|
+
return values !== null && typeof values === 'object' && !Array.isArray(values) ? { ...values, ...rest } : change;
|
|
1636
|
+
}
|
|
1637
|
+
function normalizeGmEffect(effect) {
|
|
1638
|
+
return {
|
|
1639
|
+
kind: effect.kind,
|
|
1640
|
+
personId: effect.personId ?? null, targetId: effect.targetId ?? null, sourcePersonId: effect.sourcePersonId ?? null, placeId: effect.placeId ?? null,
|
|
1641
|
+
objectId: effect.objectId ?? null, tensionId: effect.tensionId ?? null, name: effect.name ?? null,
|
|
1642
|
+
text: effect.text ?? null, condition: effect.condition ?? null, role: effect.role ?? null, amount: effect.amount ?? null,
|
|
1643
|
+
trust: effect.trust ?? null, warmth: effect.warmth ?? null, minutes: effect.minutes ?? null,
|
|
1644
|
+
connections: effect.connections ?? null, participantIds: effect.participantIds ?? null,
|
|
1645
|
+
status: effect.status ?? null, entityId: effect.entityId ?? null, kindId: effect.kindId ?? null,
|
|
1646
|
+
propertyId: effect.propertyId ?? null, relationId: effect.relationId ?? null, roleId: effect.roleId ?? null,
|
|
1647
|
+
priorId: effect.priorId ?? null, value: effect.value ?? null, choice: effect.choice ?? null,
|
|
1648
|
+
...(effect.initialProperties ? { initialProperties: effect.initialProperties } : {}),
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
const need = (value, field, kind) => {
|
|
1652
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
1653
|
+
throw new Error(`${kind} needs ${field}`);
|
|
1654
|
+
return value.trim();
|
|
1655
|
+
};
|
|
1656
|
+
// The longest role a newcomer may be given: a few words.
|
|
1657
|
+
// The side of a thing's condition a change is to, by the name the thing shows for it; none means its state.
|
|
1658
|
+
const side = (name) => {
|
|
1659
|
+
const aspect = name?.trim().slice(0, 40);
|
|
1660
|
+
return aspect ? { aspect } : {};
|
|
1661
|
+
};
|
|
1662
|
+
// The Game Master fills one flat record per effect. This maps it onto the typed
|
|
1663
|
+
// vocabulary, or explains exactly what was missing so the next round can fix it.
|
|
1664
|
+
export function translateGmEffect(state, record, seq, index, placeId = null, usedCreationIds = new Set()) {
|
|
1665
|
+
try {
|
|
1666
|
+
const playerId = state.playerId;
|
|
1667
|
+
const fresh = (kind) => `${kind}-${seq}-${index + 1}`;
|
|
1668
|
+
const occupied = (id) => usedCreationIds.has(id) || Boolean(state.entities[id] || state.objects[id]
|
|
1669
|
+
|| state.people[id] || state.places[id] || state.tensions.some(tension => tension.id === id));
|
|
1670
|
+
const newId = (kind, proposed) => {
|
|
1671
|
+
if (proposed !== null && /^[a-z0-9][a-z0-9-]{0,63}$/u.test(proposed) && !occupied(proposed))
|
|
1672
|
+
return proposed;
|
|
1673
|
+
const base = fresh(kind);
|
|
1674
|
+
let id = base;
|
|
1675
|
+
for (let suffix = 2; occupied(id); suffix += 1)
|
|
1676
|
+
id = `${base}-${suffix}`;
|
|
1677
|
+
return id;
|
|
1678
|
+
};
|
|
1679
|
+
let effect;
|
|
1680
|
+
switch (record.kind) {
|
|
1681
|
+
case 'move':
|
|
1682
|
+
effect = { kind: 'move', personId: need(record.personId, 'personId', 'move'), placeId: need(record.placeId, 'placeId', 'move') };
|
|
1683
|
+
break;
|
|
1684
|
+
case 'object.update': {
|
|
1685
|
+
const id = need(record.objectId, 'objectId', 'object.update');
|
|
1686
|
+
// A written thing or token: a holder or a place moves it; otherwise its visible condition changes.
|
|
1687
|
+
if (state.entities[id]) {
|
|
1688
|
+
if (record.targetId !== null)
|
|
1689
|
+
effect = { kind: 'entity.move', entityId: id, holderId: record.targetId, placeId: null };
|
|
1690
|
+
else if (record.placeId !== null)
|
|
1691
|
+
effect = { kind: 'entity.move', entityId: id, holderId: null, placeId: record.placeId };
|
|
1692
|
+
else if (record.condition !== null)
|
|
1693
|
+
effect = { kind: 'entity.condition', entityId: id, condition: record.condition.slice(0, 200), ...side(record.name) };
|
|
1694
|
+
else
|
|
1695
|
+
throw new Error(`${state.entities[id].name} needs a condition, a holder or a place`);
|
|
1696
|
+
}
|
|
1697
|
+
else
|
|
1698
|
+
effect = { kind: 'object.update', objectId: id, condition: record.condition, ...(record.condition === null ? {} : side(record.name)), placeId: record.targetId === null ? record.placeId : null, holderId: record.targetId, ownerId: record.personId };
|
|
1699
|
+
break;
|
|
1700
|
+
}
|
|
1701
|
+
// A written thing or token that is somewhere changes hands the same way an object does.
|
|
1702
|
+
case 'take': {
|
|
1703
|
+
const id = need(record.objectId, 'objectId', 'take');
|
|
1704
|
+
effect = state.entities[id] ? { kind: 'entity.move', entityId: id, holderId: need(record.personId, 'personId', 'take'), placeId: null }
|
|
1705
|
+
: { kind: 'object.update', objectId: id, condition: null, placeId: null, holderId: need(record.personId, 'personId', 'take'), ownerId: null };
|
|
1706
|
+
break;
|
|
1707
|
+
}
|
|
1708
|
+
case 'drop': {
|
|
1709
|
+
const id = need(record.objectId, 'objectId', 'drop');
|
|
1710
|
+
const to = record.placeId ?? placeId;
|
|
1711
|
+
if (to === null)
|
|
1712
|
+
throw new Error('drop needs a placeId');
|
|
1713
|
+
effect = state.entities[id] ? { kind: 'entity.move', entityId: id, holderId: null, placeId: to }
|
|
1714
|
+
: { kind: 'object.update', objectId: id, condition: null, placeId: to, holderId: null, ownerId: null };
|
|
1715
|
+
break;
|
|
1716
|
+
}
|
|
1717
|
+
case 'object.create':
|
|
1718
|
+
effect = { kind: 'object.create', objectId: newId('object', record.objectId), name: need(record.name, 'name', 'object.create'), summary: need(record.text, 'text', 'object.create'), placeId: record.targetId === null ? record.placeId : null, holderId: record.targetId, ownerId: record.personId, condition: record.condition ?? 'intact' };
|
|
1719
|
+
break;
|
|
1720
|
+
case 'place.condition': {
|
|
1721
|
+
const placeId = need(record.placeId, 'placeId', 'place.condition');
|
|
1722
|
+
const aspect = record.name ?? fresh('aspect');
|
|
1723
|
+
effect = { kind: 'place.condition', placeId, add: record.text, remove: null, aspect };
|
|
1724
|
+
if (effect.add === null && !state.places[placeId]?.conditionAspects[aspect])
|
|
1725
|
+
throw new Error('place.condition needs a current aspect id to resolve');
|
|
1726
|
+
break;
|
|
1727
|
+
}
|
|
1728
|
+
case 'place.create':
|
|
1729
|
+
effect = { kind: 'place.create', placeId: newId('place', record.placeId), name: need(record.name, 'name', 'place.create'), summary: need(record.text, 'text', 'place.create'), connections: record.connections ?? [] };
|
|
1730
|
+
break;
|
|
1731
|
+
case 'person.create': {
|
|
1732
|
+
// Someone already in the story is never brought in a second time, under their id or their name.
|
|
1733
|
+
const name = need(record.name, 'name', 'person.create');
|
|
1734
|
+
const same = (record.personId ? state.people[record.personId] : undefined)
|
|
1735
|
+
?? Object.values(state.people).find(person => person.departed === undefined && person.name === name);
|
|
1736
|
+
if (same)
|
|
1737
|
+
throw new Error(`${same.name} (${same.id}) is already in the story${same.isPlayer ? ' as the player' : same.away ? ', away for now' : `, in ${state.places[same.placeId]?.name ?? same.placeId}`}; bring in someone new or no one`);
|
|
1738
|
+
// A newcomer knows only what they see and are told here, never what the referee knows about them.
|
|
1739
|
+
// Their role is said once, beside their name; when they came is kept as a time, not as words that age.
|
|
1740
|
+
const role = record.role ?? 'Newcomer';
|
|
1741
|
+
// A role is what anyone could call them in a few words, not where they stand or what they hold.
|
|
1742
|
+
if (role.length > MAX_ROLE)
|
|
1743
|
+
throw new Error(`${need(record.name, 'name', 'person.create')}'s role is a few words, such as "courier" or "site visitor"; say where they stand in the narration`);
|
|
1744
|
+
const at = need(record.placeId, 'placeId', 'person.create');
|
|
1745
|
+
// They come with an aim of their own, which is their first goal; their voice is not their job title.
|
|
1746
|
+
const wants = record.text?.trim() ? record.text.trim().slice(0, MAX_LINE) : null;
|
|
1747
|
+
if (wants === null)
|
|
1748
|
+
throw new Error('person.create needs text: what brought them here and what they want now');
|
|
1749
|
+
effect = { kind: 'person.create', personId: newId('person', record.personId), name: need(record.name, 'name', 'person.create'), role,
|
|
1750
|
+
summary: `First seen at ${state.places[at]?.name ?? at}.`, persona: 'Decide from what you see and are told, and from what brought you here.', placeId: at, pronouns: null, wants };
|
|
1751
|
+
break;
|
|
1752
|
+
}
|
|
1753
|
+
case 'person.leave':
|
|
1754
|
+
effect = { kind: 'person.leave', personId: need(record.personId, 'personId', 'person.leave'), reason: need(record.text, 'text', 'person.leave').slice(0, 600) };
|
|
1755
|
+
break;
|
|
1756
|
+
case 'person.away':
|
|
1757
|
+
effect = { kind: 'person.away', personId: need(record.personId, 'personId', 'person.away'),
|
|
1758
|
+
minutes: Math.max(AWAY_MINUTES.min, Math.min(AWAY_MINUTES.max, record.minutes ?? AWAY_MINUTES.min)), reason: need(record.text, 'text', 'person.away').slice(0, 600) };
|
|
1759
|
+
break;
|
|
1760
|
+
case 'money':
|
|
1761
|
+
effect = { kind: 'money', personId: need(record.personId, 'personId', 'money'), delta: record.amount ?? 0 };
|
|
1762
|
+
if (effect.delta === 0)
|
|
1763
|
+
throw new Error('money needs a non-zero amount');
|
|
1764
|
+
break;
|
|
1765
|
+
case 'transfer': {
|
|
1766
|
+
const amount = record.amount ?? 0;
|
|
1767
|
+
if (!(amount > 0))
|
|
1768
|
+
throw new Error('transfer needs a positive amount');
|
|
1769
|
+
effect = { kind: 'transfer', fromId: record.personId?.trim() || null, toId: record.targetId?.trim() || null, amount: Math.round(amount * 100) / 100 };
|
|
1770
|
+
break;
|
|
1771
|
+
}
|
|
1772
|
+
case 'relationship':
|
|
1773
|
+
effect = { kind: 'relationship', personId: need(record.personId, 'personId', 'relationship'), targetId: need(record.targetId, 'targetId', 'relationship'), trust: record.trust ?? 0, warmth: record.warmth ?? 0, note: '' };
|
|
1774
|
+
if (effect.personId === playerId)
|
|
1775
|
+
throw new Error('The player decides their own feelings');
|
|
1776
|
+
break;
|
|
1777
|
+
case 'belief':
|
|
1778
|
+
effect = { kind: 'belief', personId: need(record.personId, 'personId', 'belief'), subject: need(record.name, 'name', 'belief'), claim: need(record.text, 'text', 'belief'), sourcePersonId: null,
|
|
1779
|
+
evidence: { kind: 'inferred', eventSeq: seq, sourceId: null } };
|
|
1780
|
+
if (effect.personId === playerId)
|
|
1781
|
+
throw new Error('The player forms their own beliefs');
|
|
1782
|
+
break;
|
|
1783
|
+
case 'commitment':
|
|
1784
|
+
effect = { kind: 'commitment', commitmentId: fresh('commitment'), personId: need(record.personId, 'personId', 'commitment'), toId: need(record.targetId, 'targetId', 'commitment'), text: need(record.text, 'text', 'commitment'), dueInMinutes: record.minutes };
|
|
1785
|
+
break;
|
|
1786
|
+
case 'goal':
|
|
1787
|
+
effect = { kind: 'goal', personId: need(record.personId, 'personId', 'goal'), add: record.text, dropGoalId: record.name };
|
|
1788
|
+
if (effect.add === null && effect.dropGoalId === null)
|
|
1789
|
+
throw new Error('goal needs text or a goal id');
|
|
1790
|
+
if (effect.personId === playerId)
|
|
1791
|
+
throw new Error('The player chooses their own goals');
|
|
1792
|
+
break;
|
|
1793
|
+
case 'mood':
|
|
1794
|
+
effect = { kind: 'mood', personId: need(record.personId, 'personId', 'mood'), mood: cleanMood(record.text, '') };
|
|
1795
|
+
if (effect.mood === '')
|
|
1796
|
+
throw new Error('mood needs a word or two of text');
|
|
1797
|
+
break;
|
|
1798
|
+
case 'hold':
|
|
1799
|
+
effect = { kind: 'hold', personId: need(record.personId, 'personId', 'hold'), byId: record.targetId, reason: record.text?.trim() ? record.text.trim().slice(0, 600) : null };
|
|
1800
|
+
break;
|
|
1801
|
+
case 'role':
|
|
1802
|
+
effect = { kind: 'role', personId: need(record.personId, 'personId', 'role'), role: need(record.text, 'text', 'role').slice(0, 200), roleId: record.roleId ?? null };
|
|
1803
|
+
break;
|
|
1804
|
+
case 'person.condition':
|
|
1805
|
+
effect = { kind: 'person.condition', personId: need(record.personId, 'personId', 'person.condition'), condition: record.text?.trim() ? record.text.trim().slice(0, 200) : null };
|
|
1806
|
+
break;
|
|
1807
|
+
case 'schedule': {
|
|
1808
|
+
if (record.personId !== null && record.personId.trim() !== '')
|
|
1809
|
+
throw new Error('A character must choose their own plan; schedule only a world happening');
|
|
1810
|
+
effect = { kind: 'schedule', scheduledId: fresh('world'), personId: null, inMinutes: Math.max(1, record.minutes ?? 30), intent: need(record.text, 'text', 'schedule'), placeId: record.placeId ?? placeId, targetId: record.targetId, scheduleKind: 'world' };
|
|
1811
|
+
if (effect.placeId === null)
|
|
1812
|
+
throw new Error('a world happening needs a placeId');
|
|
1813
|
+
break;
|
|
1814
|
+
}
|
|
1815
|
+
case 'fact': {
|
|
1816
|
+
const aspect = record.name ?? fresh('aspect');
|
|
1817
|
+
effect = { kind: 'fact', add: record.text, remove: null, aspect };
|
|
1818
|
+
if (effect.add === null && !state.factAspects[aspect])
|
|
1819
|
+
throw new Error('fact needs a current aspect id to resolve');
|
|
1820
|
+
break;
|
|
1821
|
+
}
|
|
1822
|
+
case 'tension.update':
|
|
1823
|
+
effect = { kind: 'tension.update', tensionId: need(record.tensionId, 'tensionId', 'tension.update'), status: record.status ?? 'open', note: record.text };
|
|
1824
|
+
break;
|
|
1825
|
+
case 'tension.create':
|
|
1826
|
+
effect = { kind: 'tension.create', tensionId: newId('tension', record.tensionId), title: need(record.name, 'name', 'tension.create'), summary: need(record.text, 'text', 'tension.create'), participantIds: (record.participantIds ?? []).filter(id => state.people[id]) };
|
|
1827
|
+
break;
|
|
1828
|
+
case 'incapacitate':
|
|
1829
|
+
effect = { kind: 'incapacitate', personId: need(record.personId, 'personId', 'incapacitate'), reason: need(record.text, 'text', 'incapacitate') };
|
|
1830
|
+
break;
|
|
1831
|
+
case 'message': {
|
|
1832
|
+
const message = { fromId: need(record.personId, 'personId', 'message'), toIds: [need(record.targetId, 'targetId', 'message')], text: need(record.text, 'text', 'message') };
|
|
1833
|
+
if (message.fromId === playerId)
|
|
1834
|
+
throw new Error('The world never speaks for the player');
|
|
1835
|
+
effect = state.messageTravel ? dispatch(seq, index + 1, { ...message, replies: false, sourcePersonId: record.sourcePersonId })
|
|
1836
|
+
: { kind: 'message', ...message, ...(record.sourcePersonId ? { sourcePersonId: record.sourcePersonId } : {}) };
|
|
1837
|
+
break;
|
|
1838
|
+
}
|
|
1839
|
+
case 'entity.create': {
|
|
1840
|
+
const proposedId = need(record.entityId, 'entityId', 'entity.create');
|
|
1841
|
+
// Making again a thing that is right here would make a second copy of it; an id taken by a
|
|
1842
|
+
// thing elsewhere is only a clash of names, and the new thing gets its own id.
|
|
1843
|
+
const existing = state.entities[proposedId] ?? state.objects[proposedId];
|
|
1844
|
+
const atHand = existing !== undefined && placeId !== null && (existing.placeId === placeId
|
|
1845
|
+
|| existing.holderId !== null && state.people[existing.holderId]?.placeId === placeId);
|
|
1846
|
+
if (existing && atHand)
|
|
1847
|
+
throw new Error(`${proposedId} already exists as ${existing.name}: change it, or leave it`);
|
|
1848
|
+
effect = { kind: 'entity.create', entityId: newId('entity', proposedId),
|
|
1849
|
+
// A thing made into someone's hand is where they stand, whatever place the ruling named.
|
|
1850
|
+
kindId: need(record.kindId, 'kindId', 'entity.create'), name: need(record.name, 'name', 'entity.create'),
|
|
1851
|
+
placeId: record.placeId ?? (record.personId && state.people[record.personId] ? state.people[record.personId].placeId : null),
|
|
1852
|
+
...(record.initialProperties ? { initialProperties: record.initialProperties } : {}),
|
|
1853
|
+
...(record.condition?.trim() ? { condition: record.condition.trim().slice(0, 200) } : {}), ...(record.personId ? { holderId: record.personId } : {}),
|
|
1854
|
+
...(record.kindId === 'decision' && record.participantIds?.length ? { memberIds: record.participantIds } : {}) };
|
|
1855
|
+
break;
|
|
1856
|
+
}
|
|
1857
|
+
case 'ballot': {
|
|
1858
|
+
if (record.choice !== 'yes' && record.choice !== 'no' && record.choice !== 'abstain')
|
|
1859
|
+
throw new Error('ballot needs choice');
|
|
1860
|
+
effect = { kind: 'ballot', entityId: need(record.entityId, 'entityId', 'ballot'), personId: need(record.personId, 'personId', 'ballot'), choice: record.choice };
|
|
1861
|
+
break;
|
|
1862
|
+
}
|
|
1863
|
+
case 'property.set':
|
|
1864
|
+
effect = { kind: 'property.set', entityId: need(record.entityId, 'entityId', 'property.set'), propertyId: need(record.propertyId, 'propertyId', 'property.set'), value: record.value ?? null };
|
|
1865
|
+
break;
|
|
1866
|
+
case 'relation.set':
|
|
1867
|
+
effect = { kind: 'relation.set', entityId: need(record.entityId, 'entityId', 'relation.set'), relationId: need(record.relationId, 'relationId', 'relation.set'), targetId: record.targetId };
|
|
1868
|
+
break;
|
|
1869
|
+
case 'request.open': {
|
|
1870
|
+
const senderId = need(record.personId, 'personId', 'request.open');
|
|
1871
|
+
const receiverId = need(record.targetId, 'targetId', 'request.open');
|
|
1872
|
+
if (record.priorId) {
|
|
1873
|
+
const prior = state.requests.find(request => request.id === record.priorId);
|
|
1874
|
+
if (!prior || prior.senderId !== senderId || prior.receiverId !== receiverId) {
|
|
1875
|
+
throw new Error(`Unknown request ${record.priorId} for this sender and receiver`);
|
|
1876
|
+
}
|
|
1877
|
+
throw new Error(`Request ${prior.id} was already made; its status is ${prior.status}`);
|
|
1878
|
+
}
|
|
1879
|
+
effect = { kind: 'request.open', requestId: fresh('request'), senderId, receiverId,
|
|
1880
|
+
topic: need(record.text, 'text', 'request.open') };
|
|
1881
|
+
break;
|
|
1882
|
+
}
|
|
1883
|
+
default: throw new Error(`Unknown effect kind ${String(record.kind)}`);
|
|
1884
|
+
}
|
|
1885
|
+
if (effect.kind === 'object.update' && effect.holderId !== null
|
|
1886
|
+
&& effect.holderId !== state.objects[effect.objectId]?.holderId
|
|
1887
|
+
&& state.objects[effect.objectId]?.singlePersonMovable === false) {
|
|
1888
|
+
throw new Error(`${state.objects[effect.objectId].name} cannot be carried by one person; make the part that moves a new thing, or leave it where it is`);
|
|
1889
|
+
}
|
|
1890
|
+
const moving = effect.kind === 'object.update' && effect.holderId !== null ? { thing: state.objects[effect.objectId], holderId: effect.holderId }
|
|
1891
|
+
: effect.kind === 'entity.move' && effect.holderId !== null ? { thing: state.entities[effect.entityId], holderId: effect.holderId } : null;
|
|
1892
|
+
if (moving?.thing) {
|
|
1893
|
+
const holder = state.people[moving.holderId];
|
|
1894
|
+
// A held object has no place of its own; a held typed thing is where its holder is.
|
|
1895
|
+
const fromPlace = moving.thing.placeId ?? (moving.thing.holderId ? state.people[moving.thing.holderId]?.placeId : null);
|
|
1896
|
+
if (holder && fromPlace && fromPlace !== holder.placeId) {
|
|
1897
|
+
// The repair hears where the thing is and what is within reach, so it can choose again.
|
|
1898
|
+
const atHand = [...Object.values(state.objects).filter(candidate => candidate.placeId === holder.placeId
|
|
1899
|
+
|| (candidate.holderId !== null && state.people[candidate.holderId]?.placeId === holder.placeId)).map(candidate => candidate.name),
|
|
1900
|
+
...(state.entityPlaceIds[holder.placeId] ?? []).map(id => state.entities[id].name)];
|
|
1901
|
+
throw new Error(`${moving.thing.name} is in ${state.places[fromPlace]?.name ?? fromPlace}, not with ${holder.name} in ${state.places[holder.placeId]?.name ?? holder.placeId}; `
|
|
1902
|
+
+ (atHand.length > 0 ? `things at hand there: ${atHand.join(', ')}` : 'there is nothing here to hand over'));
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
const parsed = effectSchema.safeParse(effect);
|
|
1906
|
+
if (!parsed.success)
|
|
1907
|
+
throw new Error(`${record.kind}: ${parsed.error.issues.map(issue => `${issue.path.join('.')} ${issue.message}`).join(', ')}`);
|
|
1908
|
+
return { effect: parsed.data };
|
|
1909
|
+
}
|
|
1910
|
+
catch (error) {
|
|
1911
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
// A vote a character casts in their own answer, on a decision open to them.
|
|
1915
|
+
function parseBallot(value) {
|
|
1916
|
+
const ballot = value && typeof value === 'object' ? value : null;
|
|
1917
|
+
return ballot && typeof ballot.decisionId === 'string' && (ballot.choice === 'yes' || ballot.choice === 'no' || ballot.choice === 'abstain')
|
|
1918
|
+
? { decisionId: ballot.decisionId, choice: ballot.choice } : null;
|
|
1919
|
+
}
|
|
1920
|
+
// Whether this person already said these very words in a talk still under way; no one says a line twice.
|
|
1921
|
+
// An ask still open may be recalled to the one asked once two game hours have passed since it was made
|
|
1922
|
+
// or last recalled; a sooner repeat, or one after a refusal, is held back.
|
|
1923
|
+
function reminderDue(state, request, personId, targetId) {
|
|
1924
|
+
if (!request || state.engine < REMINDER_ENGINE || request.status !== 'open' || request.senderId !== personId || request.receiverId !== targetId)
|
|
1925
|
+
return false;
|
|
1926
|
+
const last = request.remindedAt ?? request.openedAt;
|
|
1927
|
+
return last !== undefined && state.clock - last >= REMINDER_MINUTES;
|
|
1928
|
+
}
|
|
1929
|
+
function saidAlready(state, personId, text) {
|
|
1930
|
+
return state.conversations.some(conversation => conversation.endedAt === null && conversation.lines.some(line => line.speakerId === personId && line.text === text));
|
|
1931
|
+
}
|
|
1932
|
+
// The ballot effect for a vote this person may cast now, among the votes the moment concerns, or none.
|
|
1933
|
+
function ballotEffects(state, personId, ballot, concerned) {
|
|
1934
|
+
const decision = ballot && (concerned === 'any' || concerned.has(ballot.decisionId)) ? state.entities[ballot.decisionId] : undefined;
|
|
1935
|
+
return ballot && decision?.members?.includes(personId) && decision.ballots?.[personId] === undefined && votingShut(state, decision, state.clock) === null
|
|
1936
|
+
? [{ kind: 'ballot', entityId: decision.id, personId, choice: ballot.choice }] : [];
|
|
1937
|
+
}
|
|
1938
|
+
// Each reactor answers once; an answer that repeats itself or leaves English goes back
|
|
1939
|
+
// once with the reason. When the reaction is optional, a silent model means a silent
|
|
1940
|
+
// witness; the event it answers is already saved.
|
|
1941
|
+
async function react(session, reactorIds, cause, trigger, phase, optional) {
|
|
1942
|
+
// People answer one after another, so each hears what those before them said.
|
|
1943
|
+
let acted = false;
|
|
1944
|
+
for (const id of reactorIds) {
|
|
1945
|
+
const decidedIn = session.state.people[id]?.placeId;
|
|
1946
|
+
const release = await session.takeTurn(id);
|
|
1947
|
+
let reaction = null;
|
|
1948
|
+
try {
|
|
1949
|
+
// Someone who walked off, went home or left while an earlier decision of theirs was out answers this moment no more.
|
|
1950
|
+
if (decidedIn !== undefined && isHere(session.state, id, decidedIn))
|
|
1951
|
+
reaction = await reactAndRecord(session, id, cause, trigger, phase, optional);
|
|
1952
|
+
}
|
|
1953
|
+
finally {
|
|
1954
|
+
release();
|
|
1955
|
+
}
|
|
1956
|
+
if (reaction === null)
|
|
1957
|
+
continue;
|
|
1958
|
+
// One witness per moment may step in physically; the world resolves what they try, in the room it was
|
|
1959
|
+
// decided in: someone who has walked out does not do it elsewhere. An act made in answer to an act
|
|
1960
|
+
// draws words, not another act, so two people do not trade acts without end.
|
|
1961
|
+
if (reaction.act && !acted && session.answeringActs === 0 && session.canSpendOptional() && !session.state.people[id].incapacitated && !session.state.people[id].held
|
|
1962
|
+
&& isHere(session.state, id, decidedIn)
|
|
1963
|
+
&& blockedByGate(session.state, id, 'act') === null) {
|
|
1964
|
+
acted = true;
|
|
1965
|
+
session.answeringActs += 1;
|
|
1966
|
+
try {
|
|
1967
|
+
await adjudicate(session, id, reaction.act);
|
|
1968
|
+
}
|
|
1969
|
+
catch (error) {
|
|
1970
|
+
if (!(error instanceof LivingWorldError) || (error.code !== 'inference-invalid' && error.code !== 'inference-unavailable'))
|
|
1971
|
+
throw error;
|
|
1972
|
+
}
|
|
1973
|
+
finally {
|
|
1974
|
+
session.answeringActs -= 1;
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
// One person's reaction, decided and recorded; null when they decide nothing.
|
|
1980
|
+
async function reactAndRecord(session, id, cause, trigger, phase, optional) {
|
|
1981
|
+
const { reaction: decided, dropped, asked } = await decideReaction(session, id, cause, trigger, phase, optional);
|
|
1982
|
+
if (!decided)
|
|
1983
|
+
return null;
|
|
1984
|
+
// Words decided while this person said something else meanwhile, or before someone came into or
|
|
1985
|
+
// left their room, answer a moment that has passed.
|
|
1986
|
+
const place = session.state.people[id]?.placeId;
|
|
1987
|
+
const later = session.state.events.filter(event => event.seq > asked);
|
|
1988
|
+
const roomChanged = later.some(event => event.effects.some(effect => effect.kind === 'move' && effect.personId !== id
|
|
1989
|
+
&& (effect.placeId === place || event.placeId === place)));
|
|
1990
|
+
const movedOn = decided.say !== null && (roomChanged || later.some(event => event.actorId === id
|
|
1991
|
+
&& event.effects.some(effect => effect.kind === 'say' || effect.kind === 'message' || effect.kind === 'dispatch')));
|
|
1992
|
+
const reaction = movedOn ? { ...decided, say: null, request: null } : decided;
|
|
1993
|
+
const fields = reactionFields(session.state, id, reaction, cause, trigger, dropped + (movedOn ? 1 : 0));
|
|
1994
|
+
if (fields)
|
|
1995
|
+
session.commit(fields);
|
|
1996
|
+
return reaction;
|
|
1997
|
+
}
|
|
1998
|
+
async function decideReaction(session, id, cause, trigger, phase, optional) {
|
|
1999
|
+
if (optional && !session.canSpendOptional())
|
|
2000
|
+
return { reaction: null, dropped: 0, asked: session.state.revision };
|
|
2001
|
+
let errors = [];
|
|
2002
|
+
let last = null;
|
|
2003
|
+
let asked = session.state.revision;
|
|
2004
|
+
for (let round = 0; round < 2; round += 1) {
|
|
2005
|
+
if (round > 0 && !(optional ? session.canSpendOptional() : session.canSpend()))
|
|
2006
|
+
break;
|
|
2007
|
+
try {
|
|
2008
|
+
// Each try reads the world as it is when it goes; another event may have landed while the last one was out.
|
|
2009
|
+
const build = (focusIds = []) => {
|
|
2010
|
+
const state = session.state;
|
|
2011
|
+
asked = state.revision;
|
|
2012
|
+
const input = characterReactionInput(state, state.people[id], { ...trigger, thingIds: cause.thingIds ?? [] }, true, errors, focusIds);
|
|
2013
|
+
return { input, responseSchema: scopedReactionResponseSchema(input) };
|
|
2014
|
+
};
|
|
2015
|
+
const answer = await session.complete({ role: 'character', phase, system: CHARACTER_REACTION_SYSTEM, ...build(), ...LIVING_INFERENCE_LIMITS[phase] }, id, cause.seq, build);
|
|
2016
|
+
const reaction = parseReaction(answer);
|
|
2017
|
+
const problems = reactionProblems(reaction, session.state, id);
|
|
2018
|
+
if (problems.length === 0)
|
|
2019
|
+
return { reaction, dropped: 0, asked };
|
|
2020
|
+
last = reaction;
|
|
2021
|
+
// Asking again what was already asked is left unsaid below. Someone spoken to is asked once more,
|
|
2022
|
+
// so they still answer; a reaction nobody asked for is not worth a second call.
|
|
2023
|
+
if (phase === 'reaction' && problems.every(problem => typeof problem !== 'string' && problem.kind === 'request-already-made'))
|
|
2024
|
+
break;
|
|
2025
|
+
session.rejectLast('reaction-rejected');
|
|
2026
|
+
errors = problems;
|
|
2027
|
+
}
|
|
2028
|
+
catch (error) {
|
|
2029
|
+
if (error instanceof LivingWorldError && error.code === 'inference-invalid') {
|
|
2030
|
+
errors = [error.message];
|
|
2031
|
+
continue;
|
|
2032
|
+
}
|
|
2033
|
+
if (optional && error instanceof LivingWorldError && error.code === 'inference-unavailable')
|
|
2034
|
+
return { reaction: null, dropped: 0, asked };
|
|
2035
|
+
throw error;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
// What still fails its checks is left out, and the event records how much was left out: a line that
|
|
2039
|
+
// re-asks a known request, or a decision on a request this person cannot decide.
|
|
2040
|
+
if (last === null)
|
|
2041
|
+
return { reaction: null, dropped: 0, asked };
|
|
2042
|
+
const reasking = last.request?.priorId !== undefined && last.request.priorId !== null
|
|
2043
|
+
&& reactionProblems({ ...last, requestDecision: null }, session.state, id).length > 0;
|
|
2044
|
+
const undecidable = last.requestDecision !== null && reactionProblems({ ...last, request: null }, session.state, id).length > 0;
|
|
2045
|
+
const kept = { ...last, ...(reasking ? { say: null, request: null } : {}), ...(undecidable ? { requestDecision: null } : {}) };
|
|
2046
|
+
return { reaction: kept, dropped: (reasking && last.say !== null ? 1 : 0) + (undecidable ? 1 : 0), asked };
|
|
2047
|
+
}
|
|
2048
|
+
function reactionProblems(reaction, state, personId) {
|
|
2049
|
+
const problems = [];
|
|
2050
|
+
if (reaction.request?.priorId) {
|
|
2051
|
+
const prior = state.requests.find(request => request.id === reaction.request.priorId);
|
|
2052
|
+
if (!prior)
|
|
2053
|
+
problems.push({ kind: 'unknown-request', requestId: reaction.request.priorId, status: null });
|
|
2054
|
+
else if (prior.receiverId === reaction.request.receiverId && !reminderDue(state, prior, personId, reaction.request.receiverId)) {
|
|
2055
|
+
problems.push({ kind: 'request-already-made', requestId: prior.id, status: prior.status });
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
if (reaction.requestDecision) {
|
|
2059
|
+
const request = state.requests.find(item => item.id === reaction.requestDecision.id);
|
|
2060
|
+
if (!request || request.receiverId !== personId || request.status !== 'open') {
|
|
2061
|
+
problems.push({ kind: 'request-not-open', requestId: reaction.requestDecision.id, status: request?.status ?? null });
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
return problems;
|
|
2065
|
+
}
|
|
2066
|
+
function parsePlanValue(value, fallbackMinutes) {
|
|
2067
|
+
const plan = value && typeof value === 'object' ? value : null;
|
|
2068
|
+
if (!plan || typeof plan.intent !== 'string' || plan.intent.trim() === '')
|
|
2069
|
+
return null;
|
|
2070
|
+
// A line too short to show anything is no description of what the person is doing.
|
|
2071
|
+
// The model names it seenDoing; older answers and stand-ins may still call it doing or seen.
|
|
2072
|
+
const shown = typeof plan.seenDoing === 'string' ? plan.seenDoing : typeof plan.doing === 'string' ? plan.doing : plan.seen;
|
|
2073
|
+
const seen = typeof shown === 'string' && shown.trim().length >= MIN_SEEN_LENGTH ? shown.trim().slice(0, 200) : null;
|
|
2074
|
+
return { intent: plan.intent.trim().slice(0, 300), ...(seen === null ? {} : { seen }), placeId: typeof plan.placeId === 'string' ? plan.placeId : null,
|
|
2075
|
+
targetId: typeof plan.targetId === 'string' ? plan.targetId : null, inMinutes: clampInt(plan.inMinutes, 1, 100_000, fallbackMinutes) };
|
|
2076
|
+
}
|
|
2077
|
+
function parseReaction(answer) {
|
|
2078
|
+
const value = answer;
|
|
2079
|
+
if (!value || typeof value !== 'object' || typeof value.mood !== 'string')
|
|
2080
|
+
throw new LivingWorldError('inference-invalid', 'The character answer is malformed');
|
|
2081
|
+
const commitment = value.commitment && typeof value.commitment === 'object' ? value.commitment : null;
|
|
2082
|
+
const belief = value.belief && typeof value.belief === 'object' ? value.belief : null;
|
|
2083
|
+
const tell = value.tell && typeof value.tell === 'object' ? value.tell : null;
|
|
2084
|
+
const request = value.request && typeof value.request === 'object' ? value.request : null;
|
|
2085
|
+
const requestDecision = value.requestDecision && typeof value.requestDecision === 'object' ? value.requestDecision : null;
|
|
2086
|
+
return {
|
|
2087
|
+
say: typeof value.say === 'string' && value.say.trim() !== '' ? value.say.trim().slice(0, MAX_SPOKEN) : null,
|
|
2088
|
+
request: request && typeof request.topic === 'string' && request.topic.trim() !== '' && typeof request.receiverId === 'string'
|
|
2089
|
+
? { topic: request.topic.trim().slice(0, 600), receiverId: request.receiverId,
|
|
2090
|
+
priorId: typeof request.priorId === 'string' ? request.priorId : null } : null,
|
|
2091
|
+
requestDecision: requestDecision && typeof requestDecision.id === 'string'
|
|
2092
|
+
&& (requestDecision.status === 'answered' || requestDecision.status === 'refused' || requestDecision.status === 'done')
|
|
2093
|
+
? { id: requestDecision.id, status: requestDecision.status } : null,
|
|
2094
|
+
answered: typeof value.answered === 'string' ? value.answered : null,
|
|
2095
|
+
act: typeof value.act === 'string' && value.act.trim() !== '' ? value.act.trim().slice(0, 200) : null,
|
|
2096
|
+
toIds: Array.isArray(value.toIds) ? value.toIds.filter((id) => typeof id === 'string').slice(0, 4) : [],
|
|
2097
|
+
mood: cleanMood(value.mood, 'Present'),
|
|
2098
|
+
leaveToPlaceId: typeof value.leaveToPlaceId === 'string' ? value.leaveToPlaceId : null,
|
|
2099
|
+
trust: clampInt(value.trust, -20, 20, 0),
|
|
2100
|
+
warmth: clampInt(value.warmth, -20, 20, 0),
|
|
2101
|
+
relationshipNote: typeof value.relationshipNote === 'string' && value.relationshipNote.trim() !== '' ? value.relationshipNote.trim().slice(0, 200) : null,
|
|
2102
|
+
belief: belief && typeof belief.subject === 'string' && typeof belief.claim === 'string' && belief.subject.trim() !== '' && belief.claim.trim() !== '' ? { subject: belief.subject.trim().slice(0, 120), claim: belief.claim.trim().slice(0, 300) } : null,
|
|
2103
|
+
commitment: commitment && typeof commitment.toId === 'string' && typeof commitment.text === 'string' && commitment.text.trim() !== '' ? { toId: commitment.toId, text: commitment.text.trim().slice(0, 300), dueInMinutes: typeof commitment.dueInMinutes === 'number' ? clampInt(commitment.dueInMinutes, 0, 100_000, 0) : null,
|
|
2104
|
+
placeId: typeof commitment.placeId === 'string' && commitment.placeId.trim() !== '' ? commitment.placeId : null } : null,
|
|
2105
|
+
newGoal: typeof value.newGoal === 'string' && value.newGoal.trim() !== '' ? value.newGoal.trim().slice(0, 300) : null,
|
|
2106
|
+
goalDone: typeof value.goalDone === 'string' && value.goalDone.trim() !== '' ? value.goalDone.trim().slice(0, 300) : null,
|
|
2107
|
+
weight: clampInt(value.weight, 1, 10, 3),
|
|
2108
|
+
stance: value.stance === 'undecided' || value.stance === 'cooperate' || value.stance === 'refuse'
|
|
2109
|
+
|| value.stance === 'report' || value.stance === 'avoid' || value.stance === 'confront' ? value.stance : null,
|
|
2110
|
+
plan: parsePlanValue(value.plan, 30),
|
|
2111
|
+
ballot: parseBallot(value.ballot),
|
|
2112
|
+
tell: tell && typeof tell.targetId === 'string' && typeof tell.text === 'string' && tell.text.trim() !== '' ? { targetId: tell.targetId, text: tell.text.trim().slice(0, 300) } : null,
|
|
2113
|
+
suggestedReplies: Array.isArray(value.suggestedReplies) ? value.suggestedReplies.filter((item) => typeof item === 'string' && item.trim() !== '').map(item => item.trim().slice(0, 120)).slice(0, 3) : [],
|
|
2114
|
+
endConversation: value.endConversation === true,
|
|
2115
|
+
};
|
|
2116
|
+
}
|
|
2117
|
+
// A character's reaction may only change the character: their words, feelings, beliefs,
|
|
2118
|
+
// promises, plans, errands, and where they go. The reducer still checks each effect.
|
|
2119
|
+
function reactionFields(state, personId, reaction, cause, trigger, dropped = 0) {
|
|
2120
|
+
const person = state.people[personId];
|
|
2121
|
+
// Someone who went home or left while deciding says and does nothing here.
|
|
2122
|
+
if (person.away || person.departed)
|
|
2123
|
+
return null;
|
|
2124
|
+
const actorId = trigger.actorId;
|
|
2125
|
+
const present = new Set(peopleAt(state, person.placeId));
|
|
2126
|
+
const effects = [];
|
|
2127
|
+
const toIds = reaction.toIds.filter(id => present.has(id) && id !== personId);
|
|
2128
|
+
const addressed = toIds.length > 0 ? toIds : actorId !== null && present.has(actorId) && actorId !== personId ? [actorId] : [];
|
|
2129
|
+
const prior = reaction.request?.priorId ? state.requests.find(request => request.id === reaction.request.priorId) : null;
|
|
2130
|
+
const repeated = prior?.senderId === personId && prior.receiverId === reaction.request?.receiverId && !reminderDue(state, prior, personId, reaction.request?.receiverId);
|
|
2131
|
+
const reminding = prior?.senderId === personId && !repeated && reminderDue(state, prior, personId, reaction.request?.receiverId);
|
|
2132
|
+
// Words only for people who have left this person's last lines unanswered are left unsaid.
|
|
2133
|
+
const unheard = addressed.length > 0 && addressed.every(id => unansweredSince(state, personId, id) !== null);
|
|
2134
|
+
const again = reaction.say !== null && saidAlready(state, personId, reaction.say);
|
|
2135
|
+
if ((unheard || again) && reaction.say !== null)
|
|
2136
|
+
dropped += 1;
|
|
2137
|
+
const say = repeated || unheard || again || blockedByGate(state, personId, 'speak') !== null ? null : reaction.say;
|
|
2138
|
+
if (say !== null)
|
|
2139
|
+
effects.push({ kind: 'say', personId, text: say, toIds: addressed });
|
|
2140
|
+
if (say !== null && reminding && prior && addressed.includes(prior.receiverId))
|
|
2141
|
+
effects.push({ kind: 'request.remind', requestId: prior.id });
|
|
2142
|
+
if (say !== null && reaction.request && !repeated && !reminding && addressed.includes(reaction.request.receiverId)) {
|
|
2143
|
+
effects.push({ kind: 'request.open', requestId: generatedId(state.engine, 'request', [cause.seq, personId]), senderId: personId,
|
|
2144
|
+
receiverId: reaction.request.receiverId, topic: reaction.request.topic });
|
|
2145
|
+
}
|
|
2146
|
+
if (reaction.requestDecision)
|
|
2147
|
+
effects.push({ kind: 'request.update', requestId: reaction.requestDecision.id,
|
|
2148
|
+
status: reaction.requestDecision.status });
|
|
2149
|
+
const voted = ballotEffects(state, personId, reaction.ballot, new Set(cause.thingIds ?? []));
|
|
2150
|
+
effects.push(...voted);
|
|
2151
|
+
// Someone who hears their own question answered keeps it settled, with the words that settled it.
|
|
2152
|
+
const asked = reaction.answered ? state.requests.find(request => request.id === reaction.answered) : undefined;
|
|
2153
|
+
if (asked && asked.senderId === personId && asked.receiverId === actorId && asked.status === 'open' && trigger.kind === 'speech') {
|
|
2154
|
+
effects.push({ kind: 'request.update', requestId: asked.id, status: 'answered', answer: trigger.text.slice(0, 2000) });
|
|
2155
|
+
}
|
|
2156
|
+
const mood = reaction.mood;
|
|
2157
|
+
if (mood !== person.mood)
|
|
2158
|
+
effects.push({ kind: 'mood', personId, mood });
|
|
2159
|
+
const target = actorId !== null && actorId !== personId && state.people[actorId] ? actorId : null;
|
|
2160
|
+
if (target && reaction.stance !== null && (cause.perceivedBy ?? cause.witnessIds).includes(personId)) {
|
|
2161
|
+
effects.push({ kind: 'stance', personId, actorId: target, status: reaction.stance, sourceSeq: cause.seq });
|
|
2162
|
+
}
|
|
2163
|
+
if (target && (reaction.trust !== 0 || reaction.warmth !== 0)) {
|
|
2164
|
+
effects.push({ kind: 'relationship', personId, targetId: target, trust: reaction.trust, warmth: reaction.warmth, note: '' });
|
|
2165
|
+
}
|
|
2166
|
+
// What someone did is witnessed; only what someone said is taken on their word.
|
|
2167
|
+
const toldBy = trigger.kind === 'speech' || trigger.kind === 'message' ? target : null;
|
|
2168
|
+
if (reaction.belief && trigger.kind !== 'nearby')
|
|
2169
|
+
effects.push({ kind: 'belief', personId,
|
|
2170
|
+
subject: reaction.belief.subject, claim: reaction.belief.claim, sourcePersonId: toldBy,
|
|
2171
|
+
evidence: { kind: toldBy ? 'told' : 'inferred', eventSeq: cause.seq, sourceId: toldBy } });
|
|
2172
|
+
if (reaction.commitment && state.people[reaction.commitment.toId] && reaction.commitment.toId !== personId) {
|
|
2173
|
+
const where = reaction.commitment.placeId !== null && state.places[reaction.commitment.placeId] ? { placeId: reaction.commitment.placeId } : {};
|
|
2174
|
+
effects.push({ kind: 'commitment', commitmentId: generatedId(state.engine, 'commitment', [cause.seq, personId]), personId, toId: reaction.commitment.toId, text: reaction.commitment.text, dueInMinutes: reaction.commitment.dueInMinutes, ...where });
|
|
2175
|
+
}
|
|
2176
|
+
const done = reaction.goalDone ? person.goals.find(goal => goal.status === 'active' && goal.id === reaction.goalDone) : undefined;
|
|
2177
|
+
if (reaction.newGoal || done)
|
|
2178
|
+
effects.push({ kind: 'goal', personId, add: reaction.newGoal, dropGoalId: null, doneGoalId: done?.id ?? null });
|
|
2179
|
+
// Words can be a shock too: a threat, an insult or an advance said out loud weighs on the listener and gets a decision later.
|
|
2180
|
+
const relationshipRupture = (trigger.kind === 'speech' || trigger.kind === 'message') && (reaction.trust <= -5 || reaction.warmth <= -5);
|
|
2181
|
+
const assessedSpeech = trigger.kind === 'speech' && cause.importance >= SHOCK_IMPORTANCE;
|
|
2182
|
+
// Only a moment ruled serious, or one that breaks this person's trust or warmth, gives them a decision
|
|
2183
|
+
// to make later; how heavily they weigh an ordinary line does not.
|
|
2184
|
+
const shocked = assessedSpeech || relationshipRupture && cause.importance < SHOCK_IMPORTANCE;
|
|
2185
|
+
if (shocked) {
|
|
2186
|
+
if (!assessedSpeech && (cause.perceivedBy ?? cause.witnessIds).includes(personId))
|
|
2187
|
+
effects.push({ kind: 'memory', personId,
|
|
2188
|
+
text: cause.observed ?? cause.summary, importance: Math.max(reaction.weight, SHOCK_IMPORTANCE), participantIds: cause.witnessIds.filter(id => id !== personId) });
|
|
2189
|
+
}
|
|
2190
|
+
if (reaction.plan && (reaction.plan.placeId === null || state.places[reaction.plan.placeId]) && (reaction.plan.targetId === null || state.people[reaction.plan.targetId])) {
|
|
2191
|
+
// Restating a pending plan for the same place and person does not put it off.
|
|
2192
|
+
const pending = person.plan;
|
|
2193
|
+
const restated = pending !== null && (pending.placeId !== null || pending.targetId !== null)
|
|
2194
|
+
&& pending.placeId === reaction.plan.placeId && pending.targetId === reaction.plan.targetId
|
|
2195
|
+
&& pending.dueAt > state.clock && pending.dueAt < state.clock + reaction.plan.inMinutes;
|
|
2196
|
+
// The plan's id comes from the reaction's own event, so it never repeats an earlier plan's.
|
|
2197
|
+
effects.push(...planEffects(state, personId, restated ? { ...reaction.plan, inMinutes: pending.dueAt - state.clock } : reaction.plan, state.revision + 1));
|
|
2198
|
+
}
|
|
2199
|
+
if (shocked && !state.schedule.some(item => item.personId === personId && isDecision(item) && item.at <= state.clock + SHOCK_PLAN_DELAY_MINUTES)) {
|
|
2200
|
+
effects.push({ kind: 'schedule', scheduledId: generatedId(state.engine, 'shock', [cause.seq, personId]), personId, inMinutes: SHOCK_PLAN_DELAY_MINUTES, intent: `${say === null ? 'Decide what to do about this' : 'Decide what to do now, beyond what you said, about this'}: ${observedSummary(cause, personId) ?? cause.observed ?? 'what just happened'}`.slice(0, 600), placeId: null, targetId: target, scheduleKind: 'plan', importance: SHOCK_IMPORTANCE });
|
|
2201
|
+
}
|
|
2202
|
+
// Passing news on: said now to someone here, sent to an absent player, or carried as an errand to an absent character.
|
|
2203
|
+
const told = [];
|
|
2204
|
+
const tellTarget = reaction.tell && reaction.tell.targetId !== personId && reaction.tell.targetId !== actorId ? state.people[reaction.tell.targetId] : undefined;
|
|
2205
|
+
// A gate on speech stops a tell by mouth and the errand that would carry it; a gate on messages stops it by message.
|
|
2206
|
+
const gaggedSpeech = blockedByGate(state, personId, 'speak') !== null;
|
|
2207
|
+
const gaggedMessage = blockedByGate(state, personId, 'message') !== null;
|
|
2208
|
+
const tellHere = tellTarget !== undefined && isHere(state, tellTarget.id, person.placeId);
|
|
2209
|
+
const tellBlocked = tellTarget !== undefined && (tellHere ? gaggedSpeech : tellTarget.isPlayer ? gaggedMessage : gaggedSpeech);
|
|
2210
|
+
// Someone away is told nothing by mouth until they are back.
|
|
2211
|
+
if (reaction.tell && tellTarget && !tellTarget.incapacitated && !tellBlocked && (tellHere || tellTarget.isPlayer || tellTarget.away === undefined)) {
|
|
2212
|
+
if (tellHere) {
|
|
2213
|
+
if (say === null || !addressed.includes(tellTarget.id)) {
|
|
2214
|
+
effects.push({ kind: 'say', personId, text: reaction.tell.text, toIds: [tellTarget.id] });
|
|
2215
|
+
told.push(tellTarget.name);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
else if (tellTarget.isPlayer) {
|
|
2219
|
+
effects.push(state.messageTravel ? dispatch(state.revision + 1, 0, { fromId: personId, toIds: [tellTarget.id], text: reaction.tell.text, replies: false })
|
|
2220
|
+
: { kind: 'message', fromId: personId, toIds: [tellTarget.id], text: reaction.tell.text });
|
|
2221
|
+
told.push(tellTarget.name);
|
|
2222
|
+
}
|
|
2223
|
+
else if (!state.schedule.some(item => item.personId === personId && item.kind === 'errand')) {
|
|
2224
|
+
effects.push({ kind: 'schedule', scheduledId: generatedId(state.engine, 'errand', [cause.seq, personId]), personId, inMinutes: ERRAND_DELAY_MINUTES, intent: reaction.tell.text, placeId: null, targetId: tellTarget.id, scheduleKind: 'errand' });
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
if (reaction.endConversation && say === null) {
|
|
2228
|
+
const conversation = state.conversations.find(candidate => candidate.endedAt === null && candidate.placeId === person.placeId && candidate.participantIds.includes(personId));
|
|
2229
|
+
if (conversation)
|
|
2230
|
+
effects.push({ kind: 'conversation.end', conversationId: conversation.id });
|
|
2231
|
+
}
|
|
2232
|
+
let minutes = say === null && told.length === 0 ? 0 : SPEECH_MINUTES;
|
|
2233
|
+
const way = reaction.leaveToPlaceId !== null && reaction.leaveToPlaceId !== person.placeId ? travelPath(state, person.placeId, reaction.leaveToPlaceId) : null;
|
|
2234
|
+
const leaving = !heldHere(state, personId) && !blockedByGate(state, personId, 'move') && way !== null;
|
|
2235
|
+
if (leaving) {
|
|
2236
|
+
effects.push({ kind: 'move', personId, placeId: reaction.leaveToPlaceId });
|
|
2237
|
+
minutes += TRAVEL_MINUTES_PER_HOP;
|
|
2238
|
+
}
|
|
2239
|
+
// A reaction whose every change was left out still leaves a quiet record of how much was left out.
|
|
2240
|
+
const holdsBack = (count) => count === 0 ? null : {
|
|
2241
|
+
kind: 'reaction', actorId: personId, placeId: person.placeId, summary: `${person.name} holds back`.slice(0, 2000), narration: null, outcome: null,
|
|
2242
|
+
minutes: 0, effects: [], causeSeq: cause.seq, importance: 1, suggestions: [], ...quiet, droppedEffects: count,
|
|
2243
|
+
};
|
|
2244
|
+
if (effects.length === 0)
|
|
2245
|
+
return holdsBack(dropped);
|
|
2246
|
+
// A reaction with nothing said, told or done is inward: its record keeps the mood, and nobody sees it.
|
|
2247
|
+
const spoken = [
|
|
2248
|
+
...(say === null ? [] : [`says: "${say}"`]),
|
|
2249
|
+
...(told.length === 0 ? [] : [`tells ${told.join(', ')}: "${reaction.tell.text}"`]),
|
|
2250
|
+
];
|
|
2251
|
+
const outward = [...spoken, ...voted.map(effect => effect.kind === 'ballot' ? `votes ${effect.choice} on ${state.entities[effect.entityId].name}` : ''),
|
|
2252
|
+
...(leaving ? [`leaves for ${state.places[reaction.leaveToPlaceId].name}`] : [])];
|
|
2253
|
+
const fields = {
|
|
2254
|
+
kind: 'reaction', actorId: personId, placeId: person.placeId,
|
|
2255
|
+
summary: `${person.name} ${outward.length > 0 ? outward.join(' and ') : `reacts (${mood})`}`.slice(0, 2000), narration: null, outcome: null, minutes, effects,
|
|
2256
|
+
...(outward.length > 0 ? {} : { perceivedBy: [] }),
|
|
2257
|
+
// Onlookers see only the first room they head for, not where they are going.
|
|
2258
|
+
...(leaving ? { observed: `${person.name} ${[...spoken, `leaves toward ${firstRoom(state, way)}`].join(' and ')}`.slice(0, 2000) } : {}),
|
|
2259
|
+
causeSeq: cause.seq, importance: Math.max(2, Math.min(10, Math.max(cause.importance - 1, reaction.weight - 1))),
|
|
2260
|
+
suggestions: addressed.includes(state.playerId) ? reaction.suggestedReplies : [], ...quiet,
|
|
2261
|
+
...(dropped > 0 ? { droppedEffects: dropped } : {}),
|
|
2262
|
+
};
|
|
2263
|
+
try {
|
|
2264
|
+
applyLivingEvent(state, { seq: state.revision + 1, at: state.clock, witnessIds: witnessesFor(state, fields), nearbyIds: nearbyFor(state, fields), ...fields });
|
|
2265
|
+
return fields;
|
|
2266
|
+
}
|
|
2267
|
+
catch (error) {
|
|
2268
|
+
if (!(error instanceof LivingWorldError))
|
|
2269
|
+
throw error;
|
|
2270
|
+
// Keep what the reducer accepts: the spoken line and the mood, and record what was left out.
|
|
2271
|
+
const safe = effects.filter(effect => effect.kind === 'say' || effect.kind === 'mood');
|
|
2272
|
+
const { perceivedBy: _inward, observed: _leaving, ...outwardFields } = fields;
|
|
2273
|
+
return safe.length === 0 ? holdsBack(dropped + effects.length) : { ...outwardFields, effects: safe, minutes: say === null ? 0 : SPEECH_MINUTES,
|
|
2274
|
+
summary: (say === null ? `${person.name} reacts (${mood})` : `${person.name} says: "${say}"`).slice(0, 2000),
|
|
2275
|
+
...(say === null ? { perceivedBy: [] } : {}), ...(dropped + effects.length - safe.length > 0 ? { droppedEffects: dropped + effects.length - safe.length } : {}) };
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
// What onlookers see of someone setting off is the first room on their way, never where or why they go;
|
|
2279
|
+
// the player's map keeps the same room as where they were last seen heading.
|
|
2280
|
+
function firstRoom(state, path) {
|
|
2281
|
+
const next = path[1] ?? path[0];
|
|
2282
|
+
return state.places[next]?.name ?? next;
|
|
2283
|
+
}
|
|
2284
|
+
// A pending decision about someone's serious act is a plan item that carries that person's id and
|
|
2285
|
+
// the act's importance. Routine plans replace one another but never a pending decision.
|
|
2286
|
+
// A decision is a plan at shock importance about who acted, or a shock decision whose actor the
|
|
2287
|
+
// person cannot know. Routine plans are scheduled at routine importance.
|
|
2288
|
+
function isDecision(item) {
|
|
2289
|
+
return item.kind === 'plan' && item.importance >= SHOCK_IMPORTANCE
|
|
2290
|
+
&& (item.targetId !== null || item.id.startsWith('nearby-shock-') || item.id.startsWith('shock-') || item.id.startsWith('vote-'));
|
|
2291
|
+
}
|
|
2292
|
+
function planEffects(state, personId, plan, seq, suffix = '') {
|
|
2293
|
+
const existing = state.schedule.filter(item => item.personId === personId && item.kind === 'plan' && !isDecision(item));
|
|
2294
|
+
return [
|
|
2295
|
+
...existing.map((item) => ({ kind: 'unschedule', scheduledId: item.id })),
|
|
2296
|
+
{ kind: 'plan', personId, plan: { intent: plan.intent, ...(plan.seen ? { seen: plan.seen } : {}), placeId: plan.placeId, targetId: plan.targetId, dueAt: state.clock + plan.inMinutes } },
|
|
2297
|
+
{ kind: 'schedule', scheduledId: generatedId(state.engine, 'plan', [seq, personId, suffix], `plan-${seq}-${personId}${suffix}`, true), personId, inMinutes: plan.inMinutes, intent: plan.intent, placeId: plan.placeId, targetId: plan.targetId, scheduleKind: 'plan', importance: ROUTINE_PLAN_IMPORTANCE,
|
|
2298
|
+
...(state.engine >= BOUNDED_IDENTIFIERS_ENGINE && suffix === '-again' ? { replanned: true } : {}) },
|
|
2299
|
+
];
|
|
2300
|
+
}
|
|
2301
|
+
// A shock sets routine business aside: the witness decides about it a few minutes later.
|
|
2302
|
+
function shockPlanEffects(state, personId, scheduledId, intent, inMinutes, actorId) {
|
|
2303
|
+
const urgent = state.schedule.find(item => item.personId === personId
|
|
2304
|
+
&& item.at <= state.clock + inMinutes && item.importance >= SHOCK_IMPORTANCE);
|
|
2305
|
+
if (urgent)
|
|
2306
|
+
return [];
|
|
2307
|
+
return [
|
|
2308
|
+
...state.schedule.filter(item => item.personId === personId && item.kind === 'plan' && !isDecision(item)).map((item) => ({ kind: 'unschedule', scheduledId: item.id })),
|
|
2309
|
+
{ kind: 'plan', personId, plan: { intent, placeId: null, targetId: actorId, dueAt: state.clock + inMinutes } },
|
|
2310
|
+
{ kind: 'schedule', scheduledId, personId, inMinutes, intent, placeId: null, targetId: actorId, scheduleKind: 'plan', importance: SHOCK_IMPORTANCE },
|
|
2311
|
+
];
|
|
2312
|
+
}
|
|
2313
|
+
// Someone is here who is at the place and has neither left the story nor gone away.
|
|
2314
|
+
function isHere(state, personId, placeId) {
|
|
2315
|
+
return peopleAt(state, placeId).includes(personId);
|
|
2316
|
+
}
|
|
2317
|
+
// When one character speaks to another, the listener answers once, if they heard it. Nobody answers an answer.
|
|
2318
|
+
async function replyBetween(session, speakerId, listenerId, cause, text) {
|
|
2319
|
+
const listener = session.state.people[listenerId];
|
|
2320
|
+
if (!listener || listener.isPlayer || listener.incapacitated || !cause.witnessIds.includes(listenerId)
|
|
2321
|
+
|| !isHere(session.state, listenerId, session.state.people[speakerId]?.placeId ?? ''))
|
|
2322
|
+
return;
|
|
2323
|
+
await react(session, [listenerId], cause, { kind: 'speech', actorId: speakerId, text, narration: null }, 'reply', true);
|
|
2324
|
+
}
|
|
2325
|
+
// ---- Scheduled activity -------------------------------------------------------
|
|
2326
|
+
async function settleDue(session) {
|
|
2327
|
+
let handled = 0;
|
|
2328
|
+
const attempted = new Set();
|
|
2329
|
+
const recovered = new Set();
|
|
2330
|
+
for (let items = 0; items < MAX_DUE_ITEMS_PER_STEP + MAX_DUE_ERRANDS_PER_STEP; items += 1) {
|
|
2331
|
+
const state = session.state;
|
|
2332
|
+
// The schedule is ordered by time, so the due items are its head.
|
|
2333
|
+
const ready = [];
|
|
2334
|
+
for (const list of [state.schedule, state.meterSchedule])
|
|
2335
|
+
for (const item of list) {
|
|
2336
|
+
if (item.at > state.clock)
|
|
2337
|
+
break;
|
|
2338
|
+
if (!attempted.has(item.id) && predictionReady(state, item))
|
|
2339
|
+
ready.push(item);
|
|
2340
|
+
}
|
|
2341
|
+
ready.sort((left, right) => right.importance - left.importance || left.at - right.at || left.id.localeCompare(right.id));
|
|
2342
|
+
// A person whose typed cause no longer holds recovers with an event where they are, whether or
|
|
2343
|
+
// not anyone is with them; a command loads the surroundings of everyone who cannot act.
|
|
2344
|
+
const recovering = Object.values(state.people).find(person => person.incapacitatedBy !== undefined && !recovered.has(person.id)
|
|
2345
|
+
&& blockedByGate(state, person.id, 'incapacitate') === null);
|
|
2346
|
+
if (recovering) {
|
|
2347
|
+
recovered.add(recovering.id);
|
|
2348
|
+
session.commit({ kind: 'world', actorId: null, placeId: recovering.placeId, summary: `${recovering.name} can act again.`, narration: null, outcome: null, minutes: 0,
|
|
2349
|
+
effects: [], causeSeq: null, importance: 2, suggestions: [], ...quiet });
|
|
2350
|
+
continue;
|
|
2351
|
+
}
|
|
2352
|
+
const abandoned = ready.find(item => ownerCannotAct(state, item));
|
|
2353
|
+
if (abandoned) {
|
|
2354
|
+
abandon(session, abandoned);
|
|
2355
|
+
continue;
|
|
2356
|
+
}
|
|
2357
|
+
const modelAllowed = handled < MAX_DUE_ITEMS_PER_STEP && session.canSpend();
|
|
2358
|
+
const due = modelAllowed
|
|
2359
|
+
? handled === 0 ? ready.find(item => item.kind === 'world') ?? ready[0]
|
|
2360
|
+
: ready.find(item => item.kind !== 'world') ?? ready.find(item => item.kind === 'world')
|
|
2361
|
+
: ready.find(item => item.kind === 'arrive' || item.kind === 'errand' || item.kind === 'deliver' || item.kind === 'back' || item.kind === 'count');
|
|
2362
|
+
if (!due)
|
|
2363
|
+
break;
|
|
2364
|
+
const needsModel = due.kind === 'reply' || due.kind === 'plan' || due.kind === 'world' || due.kind === 'finish';
|
|
2365
|
+
if (needsModel)
|
|
2366
|
+
handled += 1;
|
|
2367
|
+
// An item the model could not answer stays due, but does not block other ready work.
|
|
2368
|
+
if (!await dispatchItem(session, due))
|
|
2369
|
+
attempted.add(due.id);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
function ownerCannotAct(state, item) {
|
|
2373
|
+
const owner = item.personId === null ? null : state.people[item.personId];
|
|
2374
|
+
// Someone away decides nothing until they are back; a plan or errand that came due meanwhile is set aside.
|
|
2375
|
+
return item.personId !== null && (!owner || owner.incapacitated || owner.isPlayer || owner.departed !== undefined
|
|
2376
|
+
|| owner.away !== undefined && (item.kind === 'plan' || item.kind === 'arrive' || item.kind === 'errand'));
|
|
2377
|
+
}
|
|
2378
|
+
// The event cites the complete scheduled intent by sourceSeq; only its display summary is shortened.
|
|
2379
|
+
function intentSummary(prefix, intent, suffix) {
|
|
2380
|
+
const available = MAX_TEXT - prefix.length - suffix.length;
|
|
2381
|
+
if (intent.length <= available)
|
|
2382
|
+
return prefix + intent + suffix;
|
|
2383
|
+
let excerpt = intent.slice(0, available - 1);
|
|
2384
|
+
const last = excerpt.charCodeAt(excerpt.length - 1);
|
|
2385
|
+
if (last >= 0xd800 && last <= 0xdbff)
|
|
2386
|
+
excerpt = excerpt.slice(0, -1);
|
|
2387
|
+
return prefix + excerpt + '…' + suffix;
|
|
2388
|
+
}
|
|
2389
|
+
function abandon(session, item) {
|
|
2390
|
+
session.commit({ kind: 'plan', actorId: null, placeId: item.placeId, summary: intentSummary('The appointment to ', item.intent, ' cannot proceed because its owner cannot act.'),
|
|
2391
|
+
narration: null, outcome: null, minutes: 0, effects: [{ kind: 'unschedule', scheduledId: item.id }], causeSeq: item.sourceSeq || null, importance: 2, suggestions: [], ...quiet });
|
|
2392
|
+
}
|
|
2393
|
+
function dispatchItem(session, due) {
|
|
2394
|
+
switch (due.kind) {
|
|
2395
|
+
case 'reply': return answerMessage(session, due);
|
|
2396
|
+
case 'arrive': return arriveAndSpeak(session, due);
|
|
2397
|
+
case 'errand': return runErrand(session, due);
|
|
2398
|
+
case 'world': return worldFollowUp(session, due);
|
|
2399
|
+
case 'plan': return carryOutPlan(session, due, true);
|
|
2400
|
+
case 'deliver': return deliverMessage(session, due);
|
|
2401
|
+
case 'finish': return finishAct(session, due);
|
|
2402
|
+
case 'back': return comeBack(session, due);
|
|
2403
|
+
case 'count': return countVote(session, due);
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
// A vote opens or is counted at its written time, with no model. Its opening gives each member who has
|
|
2407
|
+
// not voted a turn to decide; at its close the ballots cast decide the result. Both are news everywhere.
|
|
2408
|
+
async function countVote(session, due) {
|
|
2409
|
+
const state = session.state;
|
|
2410
|
+
const decision = due.count ? state.entities[due.count.entityId] : undefined;
|
|
2411
|
+
const done = [{ kind: 'unschedule', scheduledId: due.id }];
|
|
2412
|
+
const base = { actorId: null, placeId: decision?.placeId ?? due.placeId, narration: null, outcome: null, minutes: 0, causeSeq: due.sourceSeq || null, suggestions: [], ...quiet };
|
|
2413
|
+
if (!decision || decision.members === undefined || decision.result !== undefined) {
|
|
2414
|
+
session.commit({ kind: 'world', ...base, summary: intentSummary('', due.intent, ': nothing left to count.'), effects: done, importance: 1 });
|
|
2415
|
+
return true;
|
|
2416
|
+
}
|
|
2417
|
+
if (due.count.opens) {
|
|
2418
|
+
const question = String(decision.properties.question?.value ?? decision.name).slice(0, 300);
|
|
2419
|
+
const voters = decision.members.map(id => state.people[id]).filter((person) => person !== undefined
|
|
2420
|
+
&& !person.isPlayer && person.departed === undefined && decision.ballots?.[person.id] === undefined);
|
|
2421
|
+
session.commit({ kind: 'world', ...base, summary: `Voting opens on ${decision.name}.`, observed: `Voting opens on ${decision.name}.`, importance: 4,
|
|
2422
|
+
reach: 'everywhere', notice: `Voting opens on ${decision.name}.`,
|
|
2423
|
+
effects: [...done, ...voters.map((person, index) => ({ kind: 'schedule', scheduledId: generatedId(state.engine, 'vote', [state.revision + 1, person.id], `vote-${state.revision + 1}-${person.id}`.slice(0, 64)), personId: person.id,
|
|
2424
|
+
inMinutes: 1 + index, intent: `Cast your vote on ${decision.name}: ${question}`.slice(0, MAX_LINE), placeId: null, targetId: null, scheduleKind: 'plan', importance: PROMISE_IMPORTANCE }))] });
|
|
2425
|
+
return true;
|
|
2426
|
+
}
|
|
2427
|
+
const result = countResult(decision);
|
|
2428
|
+
session.commit({ kind: 'world', ...base, summary: `The votes on ${decision.name} are counted: ${result}.`, observed: `The votes on ${decision.name} are counted: ${result}.`, importance: 6,
|
|
2429
|
+
reach: 'everywhere', notice: `The votes on ${decision.name} are counted: ${result}.`,
|
|
2430
|
+
effects: [...done, { kind: 'decision.close', entityId: decision.id, result }] });
|
|
2431
|
+
return true;
|
|
2432
|
+
}
|
|
2433
|
+
// Someone away comes back to where they left from when their time away is up; what to do next
|
|
2434
|
+
// they decide there.
|
|
2435
|
+
async function comeBack(session, due) {
|
|
2436
|
+
const person = session.state.people[due.personId];
|
|
2437
|
+
session.commit({ kind: 'move', actorId: person.id, placeId: person.placeId, summary: `${person.name} comes back`, narration: null, outcome: null, minutes: 0,
|
|
2438
|
+
effects: [{ kind: 'unschedule', scheduledId: due.id }, ...(person.away ? [{ kind: 'person.back', personId: person.id }] : []), { kind: 'plan', personId: person.id, plan: null }],
|
|
2439
|
+
causeSeq: due.sourceSeq || null, importance: 1, suggestions: [], ...quiet, observed: `${person.name} comes in.` });
|
|
2440
|
+
return true;
|
|
2441
|
+
}
|
|
2442
|
+
async function settleItem(session, due) {
|
|
2443
|
+
if (ownerCannotAct(session.state, due)) {
|
|
2444
|
+
abandon(session, due);
|
|
2445
|
+
return true;
|
|
2446
|
+
}
|
|
2447
|
+
return dispatchItem(session, due);
|
|
2448
|
+
}
|
|
2449
|
+
// A character who walked over to say something says it on arrival, no model needed.
|
|
2450
|
+
// If the person they came for has gone, they decide afresh a little later.
|
|
2451
|
+
async function arriveAndSpeak(session, due) {
|
|
2452
|
+
const state = session.state;
|
|
2453
|
+
const person = state.people[due.personId];
|
|
2454
|
+
const target = due.targetId ? state.people[due.targetId] : undefined;
|
|
2455
|
+
const cause = due.sourceSeq || null;
|
|
2456
|
+
if (target && (unansweredSince(state, person.id, target.id) !== null || saidAlready(state, person.id, due.intent))) {
|
|
2457
|
+
// Someone who has left this person's last lines unanswered, or already heard these words, is not told it again on arrival.
|
|
2458
|
+
session.commit({
|
|
2459
|
+
kind: 'plan', actorId: person.id, placeId: person.placeId, summary: `${person.name} leaves it unsaid: ${target.name} ${unansweredSince(state, person.id, target.id) !== null ? 'has not answered' : 'has heard it already'}`, narration: null, outcome: null, minutes: 0,
|
|
2460
|
+
effects: [{ kind: 'unschedule', scheduledId: due.id }, ...(session.live() ? planEffects({ ...state, schedule: state.schedule.filter(item => item.id !== due.id) }, person.id, { intent: 'Carry on with the day', placeId: null, targetId: null, inMinutes: DEFAULT_ACTIVITY_MINUTES }, state.revision + 1) : [{ kind: 'plan', personId: person.id, plan: null }])],
|
|
2461
|
+
causeSeq: cause, importance: 1, suggestions: [], ...quiet, droppedEffects: 1,
|
|
2462
|
+
});
|
|
2463
|
+
return true;
|
|
2464
|
+
}
|
|
2465
|
+
if (target && isHere(state, target.id, person.placeId) && !target.incapacitated && blockedByGate(state, person.id, 'speak') === null) {
|
|
2466
|
+
const event = session.commit({
|
|
2467
|
+
kind: 'reaction', actorId: person.id, placeId: person.placeId, summary: `${person.name} says to ${target.name}: "${due.intent}"`.slice(0, 2000), narration: null, outcome: null, minutes: SPEECH_MINUTES,
|
|
2468
|
+
effects: [{ kind: 'unschedule', scheduledId: due.id }, { kind: 'say', personId: person.id, text: due.intent, toIds: [target.id] },
|
|
2469
|
+
...(session.live() ? planEffects({ ...state, schedule: state.schedule.filter(item => item.id !== due.id) }, person.id, { intent: `Talk with ${target.name}`, seen: `talking with ${target.name}`, placeId: null, targetId: target.id, inMinutes: DEFAULT_ACTIVITY_MINUTES }, state.revision + 1)
|
|
2470
|
+
: [{ kind: 'plan', personId: person.id, plan: null }])],
|
|
2471
|
+
causeSeq: cause, importance: 3, suggestions: [], ...quiet,
|
|
2472
|
+
});
|
|
2473
|
+
await replyBetween(session, person.id, target.id, event, due.intent);
|
|
2474
|
+
return true;
|
|
2475
|
+
}
|
|
2476
|
+
const seq = state.revision + 1;
|
|
2477
|
+
session.commit({
|
|
2478
|
+
kind: 'plan', actorId: person.id, placeId: person.placeId, summary: `${person.name} arrives, but ${target?.name ?? 'the person they came for'} is not here`, narration: null, outcome: null, minutes: 0,
|
|
2479
|
+
effects: [{ kind: 'unschedule', scheduledId: due.id }, ...planEffects({ ...state, schedule: state.schedule.filter(item => item.id !== due.id) }, person.id, { intent: `Find ${target?.name ?? 'someone'} and say: ${due.intent}`.slice(0, 300), placeId: null, targetId: target?.id ?? null, inMinutes: 30 }, seq)],
|
|
2480
|
+
causeSeq: cause, importance: 1, suggestions: [], ...quiet,
|
|
2481
|
+
});
|
|
2482
|
+
return true;
|
|
2483
|
+
}
|
|
2484
|
+
// An errand is a walk to tell someone something: no model, just feet and a sentence.
|
|
2485
|
+
async function runErrand(session, due) {
|
|
2486
|
+
const state = session.state;
|
|
2487
|
+
const person = state.people[due.personId];
|
|
2488
|
+
const target = due.targetId ? state.people[due.targetId] : undefined;
|
|
2489
|
+
const cause = due.sourceSeq || null;
|
|
2490
|
+
const drop = (why) => session.commit({
|
|
2491
|
+
kind: 'plan', actorId: person.id, placeId: person.placeId, summary: `${person.name} lets it go: ${why}`, narration: null, outcome: null, minutes: 0,
|
|
2492
|
+
effects: [{ kind: 'unschedule', scheduledId: due.id }], causeSeq: cause, importance: 1, suggestions: [], ...quiet,
|
|
2493
|
+
});
|
|
2494
|
+
if (!target || target.incapacitated) {
|
|
2495
|
+
drop(`${target?.name ?? 'the person'} cannot be told now`);
|
|
2496
|
+
return true;
|
|
2497
|
+
}
|
|
2498
|
+
if (unansweredSince(state, person.id, target.id) !== null) {
|
|
2499
|
+
drop(`${target.name} has not answered`);
|
|
2500
|
+
return true;
|
|
2501
|
+
}
|
|
2502
|
+
if ((heldHere(state, person.id) || blockedByGate(state, person.id, 'move')) && target.placeId !== person.placeId) {
|
|
2503
|
+
drop(`${person.name} cannot leave to find ${target.name}`);
|
|
2504
|
+
return true;
|
|
2505
|
+
}
|
|
2506
|
+
if (blockedByGate(state, person.id, 'speak') !== null) {
|
|
2507
|
+
drop(`${person.name} cannot speak now`);
|
|
2508
|
+
return true;
|
|
2509
|
+
}
|
|
2510
|
+
if (isHere(state, target.id, person.placeId)) {
|
|
2511
|
+
const event = session.commit({
|
|
2512
|
+
kind: 'reaction', actorId: person.id, placeId: person.placeId, summary: `${person.name} says to ${target.name}: "${due.intent}"`.slice(0, 2000), narration: null, outcome: null, minutes: SPEECH_MINUTES,
|
|
2513
|
+
effects: [{ kind: 'unschedule', scheduledId: due.id }, { kind: 'say', personId: person.id, text: due.intent, toIds: [target.id] }],
|
|
2514
|
+
causeSeq: cause, importance: 3, suggestions: [], ...quiet,
|
|
2515
|
+
});
|
|
2516
|
+
await replyBetween(session, person.id, target.id, event, due.intent);
|
|
2517
|
+
return true;
|
|
2518
|
+
}
|
|
2519
|
+
const path = travelPath(state, person.placeId, target.placeId);
|
|
2520
|
+
if (!path) {
|
|
2521
|
+
drop(`there is no way to reach ${target.name}`);
|
|
2522
|
+
return true;
|
|
2523
|
+
}
|
|
2524
|
+
const seq = state.revision + 1;
|
|
2525
|
+
session.commit({
|
|
2526
|
+
kind: 'move', actorId: person.id, placeId: person.placeId, summary: `${person.name} travels to ${state.places[target.placeId].name} to find ${target.name}`, narration: null, outcome: null, minutes: (path.length - 1) * TRAVEL_MINUTES_PER_HOP,
|
|
2527
|
+
observed: `${person.name} leaves toward ${firstRoom(state, path)}.`,
|
|
2528
|
+
effects: [
|
|
2529
|
+
{ kind: 'unschedule', scheduledId: due.id },
|
|
2530
|
+
{ kind: 'move', personId: person.id, placeId: target.placeId },
|
|
2531
|
+
// What to tell them is chosen on arrival, from what is there then.
|
|
2532
|
+
...planEffects({ ...state, schedule: state.schedule.filter(item => item.id !== due.id) }, person.id, { intent: `Tell ${target.name}: ${due.intent}`.slice(0, 300),
|
|
2533
|
+
seen: `on the way to ${target.name}`, placeId: target.placeId, targetId: target.id, inMinutes: (path.length - 1) * TRAVEL_MINUTES_PER_HOP }, seq),
|
|
2534
|
+
],
|
|
2535
|
+
causeSeq: cause, importance: 2, suggestions: [], ...quiet,
|
|
2536
|
+
});
|
|
2537
|
+
return true;
|
|
2538
|
+
}
|
|
2539
|
+
// A happening that comes back is set up again for its next written time as this one is settled,
|
|
2540
|
+
// however late this one is ruled; times missed meanwhile are skipped, not stacked.
|
|
2541
|
+
function nextTime(due) {
|
|
2542
|
+
return (state) => {
|
|
2543
|
+
if (due.everyMinutes === undefined)
|
|
2544
|
+
return [];
|
|
2545
|
+
const next = due.at + due.everyMinutes * Math.max(1, Math.floor((state.clock - due.at) / due.everyMinutes) + 1);
|
|
2546
|
+
return [{ kind: 'schedule', scheduledId: generatedId(state.engine, 'repeat', [due.series ?? due.id, next], `${due.series ?? due.id}-${next}`.slice(0, 64), true),
|
|
2547
|
+
personId: null, inMinutes: next - state.clock, intent: due.intent, placeId: due.placeId, targetId: due.targetId, scheduleKind: 'world', importance: due.importance,
|
|
2548
|
+
everyMinutes: due.everyMinutes, series: due.series ?? due.id, ...(due.beat === undefined ? {} : { beat: due.beat }) }];
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
2551
|
+
// The typed results a world's data gives one of its happenings, as far as the world can take them now:
|
|
2552
|
+
// facts that replace their aspect, a problem's new state, money that moves.
|
|
2553
|
+
function beatResults(state, placeId, beat) {
|
|
2554
|
+
if (!beat?.then)
|
|
2555
|
+
return [];
|
|
2556
|
+
const tension = beat.then.tension ? state.tensions.find(candidate => candidate.id === beat.then.tension.id) : undefined;
|
|
2557
|
+
const results = [
|
|
2558
|
+
...(beat.then.facts ?? []).map((fact) => ({ kind: 'fact', add: fact.text, remove: null, aspect: fact.aspect })),
|
|
2559
|
+
...(tension && beat.then.tension ? [{ kind: 'tension.update', tensionId: tension.id, status: beat.then.tension.status ?? tension.status, note: beat.then.tension.summary ?? null }] : []),
|
|
2560
|
+
...(beat.then.transfers ?? []).map((transfer) => ({ kind: 'transfer', ...transfer })),
|
|
2561
|
+
];
|
|
2562
|
+
// Each result is kept only if the world can take it now, such as a cost the money on hand can pay.
|
|
2563
|
+
const draft = { kind: 'world', actorId: null, placeId, summary: 'A due happening.', narration: null, outcome: null, minutes: 0, causeSeq: null, importance: 1, suggestions: [], reach: 'here', notice: null, effects: [] };
|
|
2564
|
+
const kept = [];
|
|
2565
|
+
for (const effect of results) {
|
|
2566
|
+
try {
|
|
2567
|
+
applyLivingEvent(state, { seq: state.revision + 1, at: state.clock, witnessIds: witnessesFor(state, draft), nearbyIds: nearbyFor(state, draft), ...draft, effects: [...kept, effect] });
|
|
2568
|
+
kept.push(effect);
|
|
2569
|
+
}
|
|
2570
|
+
catch (error) {
|
|
2571
|
+
if (!(error instanceof LivingWorldError))
|
|
2572
|
+
throw error;
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
return kept;
|
|
2576
|
+
}
|
|
2577
|
+
function describeResult(state, effect) {
|
|
2578
|
+
const holder = (id) => id === null ? 'outside the world' : state.accounts?.[id]?.name ?? personLabel(state, id);
|
|
2579
|
+
switch (effect.kind) {
|
|
2580
|
+
case 'fact': return effect.add ?? '';
|
|
2581
|
+
case 'tension.update': return `${state.tensions.find(tension => tension.id === effect.tensionId)?.title ?? effect.tensionId}: ${effect.note ?? effect.status}`;
|
|
2582
|
+
case 'transfer': return `${effect.amount}${state.currency ? ` ${state.currency.name}` : ''} pass from ${holder(effect.fromId)} to ${holder(effect.toId)}`;
|
|
2583
|
+
default: return '';
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
// Every two game hours the world takes up the open problem nobody has acted on longest and rules how
|
|
2587
|
+
// it goes on by itself: the rigs unload, the patient worsens, the deadline passes.
|
|
2588
|
+
async function stallCheck(session, due) {
|
|
2589
|
+
const state = session.state;
|
|
2590
|
+
const again = nextTime(due);
|
|
2591
|
+
const stalled = state.tensions.filter(tension => tension.status === 'open' && state.clock - tension.changedAt >= STALL_MINUTES)
|
|
2592
|
+
.sort((left, right) => left.changedAt - right.changedAt || left.id.localeCompare(right.id))[0];
|
|
2593
|
+
if (stalled === undefined) {
|
|
2594
|
+
// The engine's own check is no event in the world: no one sees it.
|
|
2595
|
+
session.commit({ kind: 'world', actorId: null, placeId: session.player().placeId, summary: 'No open problem has stood still', narration: null, outcome: null, minutes: 0,
|
|
2596
|
+
effects: [{ kind: 'unschedule', scheduledId: due.id }, ...again(state)], causeSeq: null, importance: 1, suggestions: [], ...quiet, perceivedBy: [] });
|
|
2597
|
+
return true;
|
|
2598
|
+
}
|
|
2599
|
+
// It goes on where those it concerns are.
|
|
2600
|
+
const placeId = stalled.participantIds.map(id => state.people[id]).find(person => person !== undefined && !person.isPlayer && !person.away && !person.departed)?.placeId
|
|
2601
|
+
?? session.player().placeId;
|
|
2602
|
+
try {
|
|
2603
|
+
await resolve(session, {
|
|
2604
|
+
phase: 'world', actorId: null, placeId, kind: 'world', causeSeq: null, extraEffects: [{ kind: 'unschedule', scheduledId: due.id }], clears: again,
|
|
2605
|
+
input: (errors, fitted, focusIds) => gameMasterWorldInput(session.state, { intent: `${stalled.title}: ${stalled.summary}`.slice(0, 600), placeId, sourceSeq: 0, targetId: stalled.id, id: due.id }, errors, fitted, focusIds),
|
|
2606
|
+
summary: ruling => ruling.record,
|
|
2607
|
+
trigger: ruling => ({ kind: 'world', actorId: null, text: ruling.observed ?? ruling.narration, narration: ruling.narration }),
|
|
2608
|
+
// What the ruling made of it becomes where the problem now stands.
|
|
2609
|
+
rulingEffects: (_state, ruling) => ruling.settledTensionId === stalled.id ? [] : [{ kind: 'tension.update', tensionId: stalled.id, status: 'open', note: ruling.record.slice(0, 2000) }],
|
|
2610
|
+
movesNoMoney: true,
|
|
2611
|
+
// Where the problem stood, said again word for word, is no development.
|
|
2612
|
+
nothingNew: ruling => ruling.settledTensionId === null && ruling.newTension === null && ruling.effects.length === 0 && ruling.record.trim() === stalled.summary.trim(),
|
|
2613
|
+
});
|
|
2614
|
+
return true;
|
|
2615
|
+
}
|
|
2616
|
+
catch (error) {
|
|
2617
|
+
if (!(error instanceof LivingWorldError))
|
|
2618
|
+
throw error;
|
|
2619
|
+
if (error.code === 'inference-unavailable' || error.code === 'inference-invalid')
|
|
2620
|
+
return false;
|
|
2621
|
+
throw error;
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
// A world happening that fell due is carried out by the Game Master: the ambulances
|
|
2625
|
+
// arrive, the fire spreads or dies, the storm hits, security walks in.
|
|
2626
|
+
async function worldFollowUp(session, due) {
|
|
2627
|
+
if (due.series === 'stall-check')
|
|
2628
|
+
return stallCheck(session, due);
|
|
2629
|
+
const state = session.state;
|
|
2630
|
+
const placeId = due.placeId && state.places[due.placeId] ? due.placeId : session.player().placeId;
|
|
2631
|
+
const beat = due.beat;
|
|
2632
|
+
// A deadline whose problem was settled first does not happen; no one needs to rule it.
|
|
2633
|
+
const settledFirst = beat?.unless ? state.tensions.find(tension => tension.id === beat.unless.settled && tension.status === 'settled') : undefined;
|
|
2634
|
+
if (settledFirst) {
|
|
2635
|
+
session.commit({ kind: 'world', actorId: null, placeId, summary: `Averted: ${due.intent} (${settledFirst.title} was settled first)`.slice(0, 2000), narration: null, outcome: null, minutes: 0,
|
|
2636
|
+
effects: [{ kind: 'unschedule', scheduledId: due.id }, ...nextTime(due)(state),
|
|
2637
|
+
...(beat.deadline ? [{ kind: 'deadline.passed', what: due.intent.slice(0, MAX_LINE), outcome: `Averted: ${settledFirst.title} was settled first.`.slice(0, MAX_LINE),
|
|
2638
|
+
...(state.engine >= AVERTED_ENGINE ? { averted: true } : {}) }] : [])],
|
|
2639
|
+
causeSeq: null, importance: 2, suggestions: [], ...quiet });
|
|
2640
|
+
return true;
|
|
2641
|
+
}
|
|
2642
|
+
// What the world's data says this happening changes applies whatever the Game Master rules.
|
|
2643
|
+
const typed = (world) => beatResults(world, placeId, beat);
|
|
2644
|
+
try {
|
|
2645
|
+
const again = nextTime(due);
|
|
2646
|
+
await resolve(session, {
|
|
2647
|
+
phase: 'world', actorId: null, placeId, kind: 'world', causeSeq: due.sourceSeq || null, extraEffects: [{ kind: 'unschedule', scheduledId: due.id }], clears: again, concerns: due.targetId,
|
|
2648
|
+
input: (errors, fitted, focusIds) => gameMasterWorldInput(session.state, { intent: due.intent, placeId, sourceSeq: due.sourceSeq, targetId: due.targetId, id: due.id,
|
|
2649
|
+
...(due.everyMinutes === undefined ? {} : { everyMinutes: due.everyMinutes }), alreadyTrue: typed(session.state).map(effect => describeResult(session.state, effect)) }, errors, fitted, focusIds),
|
|
2650
|
+
rulingEffects: (world, ruling) => [...typed(world),
|
|
2651
|
+
...(beat?.deadline ? [{ kind: 'deadline.passed', what: due.intent.slice(0, MAX_LINE), outcome: (ruling.observed ?? ruling.record).slice(0, MAX_LINE) }] : [])],
|
|
2652
|
+
...(beat?.reach ? { reach: { reach: beat.reach, notice: beat.heard ?? null } } : {}),
|
|
2653
|
+
summary: ruling => ruling.record,
|
|
2654
|
+
// What the world's data says happens is seen by everyone there.
|
|
2655
|
+
seenByAll: (due.series ?? due.id).startsWith('beat-'),
|
|
2656
|
+
// A happening that comes back is told anew each time.
|
|
2657
|
+
repeats: ruling => {
|
|
2658
|
+
const before = due.everyMinutes === undefined ? undefined : earlierTimes(session.state, due.sourceSeq).find(time => time.what === ruling.observed);
|
|
2659
|
+
return before ? `This is word for word what happened at ${before.at}; tell what happens this time.` : null;
|
|
2660
|
+
},
|
|
2661
|
+
// People there perceive the happening, never the intent or record that caused it.
|
|
2662
|
+
trigger: ruling => ({ kind: 'world', actorId: null, text: ruling.observed ?? ruling.narration, narration: ruling.narration }),
|
|
2663
|
+
});
|
|
2664
|
+
return true;
|
|
2665
|
+
}
|
|
2666
|
+
catch (error) {
|
|
2667
|
+
if (!(error instanceof LivingWorldError))
|
|
2668
|
+
throw error;
|
|
2669
|
+
if (error.code !== 'inference-unavailable' && error.code !== 'inference-invalid')
|
|
2670
|
+
throw error;
|
|
2671
|
+
// A deadline comes whether or not anyone can tell it well: a little after its time it happens
|
|
2672
|
+
// with what its data says it changes, in its own words and as far as it carries.
|
|
2673
|
+
if (beat?.deadline && session.state.clock - due.at >= DEADLINE_GRACE_MINUTES) {
|
|
2674
|
+
const world = session.state;
|
|
2675
|
+
session.commit({ kind: 'world', actorId: null, placeId, summary: due.intent, narration: null, outcome: null, minutes: 0,
|
|
2676
|
+
effects: [{ kind: 'unschedule', scheduledId: due.id }, ...nextTime(due)(world), ...typed(world),
|
|
2677
|
+
{ kind: 'deadline.passed', what: due.intent.slice(0, MAX_LINE), outcome: (beat.heard ?? due.intent).slice(0, MAX_LINE) }],
|
|
2678
|
+
causeSeq: due.sourceSeq || null, importance: due.importance, suggestions: [], ...quiet, observed: beat.heard ?? due.intent,
|
|
2679
|
+
...(beat.reach ? { reach: beat.reach, notice: beat.heard ?? null } : {}) });
|
|
2680
|
+
return true;
|
|
2681
|
+
}
|
|
2682
|
+
// Keep the due item for a later try. A failed answer says nothing about whether the event happened.
|
|
2683
|
+
return false;
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
// With nothing scheduled, idle characters think about what to do next.
|
|
2687
|
+
async function idleTick(session) {
|
|
2688
|
+
const state = session.state;
|
|
2689
|
+
const lastDecision = (personId) => [...state.events].reverse().find(event => event.actorId === personId && event.kind === 'plan')?.seq ?? 0;
|
|
2690
|
+
// A promise kept later is no plan for now; the person still decides what to do meanwhile.
|
|
2691
|
+
const idle = Object.values(state.people).filter(person => !person.isPlayer && !person.incapacitated && person.plan === null
|
|
2692
|
+
&& !hasPlanned(state, person.id))
|
|
2693
|
+
.sort((left, right) => lastDecision(left.id) - lastDecision(right.id) || left.id.localeCompare(right.id));
|
|
2694
|
+
for (const person of idle.slice(0, MAX_DUE_ITEMS_PER_STEP)) {
|
|
2695
|
+
if (!session.canSpend())
|
|
2696
|
+
break;
|
|
2697
|
+
await carryOutPlan(session, { id: generatedId(session.state.engine, 'idle', [session.state.revision + 1, person.id]), at: session.state.clock, personId: person.id, kind: 'plan', intent: 'idle', placeId: null, targetId: null, sourceSeq: session.state.revision, importance: 1 }, false);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
function parsePlan(answer) {
|
|
2701
|
+
const value = answer;
|
|
2702
|
+
const actions = ['go', 'speak', 'message', 'attempt', 'go-home', 'wait'];
|
|
2703
|
+
if (!value || typeof value !== 'object' || typeof value.action !== 'string' || !actions.includes(value.action) || typeof value.mood !== 'string')
|
|
2704
|
+
throw new LivingWorldError('inference-invalid', 'The plan answer is malformed');
|
|
2705
|
+
const text = typeof value.text === 'string' && value.text.trim() !== '' ? value.text.trim().slice(0, MAX_SPOKEN) : null;
|
|
2706
|
+
const request = value.request && typeof value.request === 'object' ? value.request : null;
|
|
2707
|
+
return {
|
|
2708
|
+
action: value.action,
|
|
2709
|
+
placeId: typeof value.placeId === 'string' ? value.placeId : null,
|
|
2710
|
+
targetId: typeof value.targetId === 'string' ? value.targetId : null,
|
|
2711
|
+
text,
|
|
2712
|
+
mood: cleanMood(value.mood, 'Present'),
|
|
2713
|
+
nextPlan: parsePlanValue(value.nextPlan, 60),
|
|
2714
|
+
request: request && typeof request.topic === 'string' && request.topic.trim() !== ''
|
|
2715
|
+
? { topic: request.topic.trim().slice(0, 600), priorId: typeof request.priorId === 'string' ? request.priorId : null } : null,
|
|
2716
|
+
stance: ['undecided', 'cooperate', 'refuse', 'report', 'avoid', 'confront'].includes(value.stance) ? value.stance : null,
|
|
2717
|
+
ballot: parseBallot(value.ballot),
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
async function carryOutPlan(session, due, scheduled) {
|
|
2721
|
+
const release = await session.takeTurn(due.personId);
|
|
2722
|
+
try {
|
|
2723
|
+
return await decidePlan(session, due, scheduled, release);
|
|
2724
|
+
}
|
|
2725
|
+
finally {
|
|
2726
|
+
release();
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
// The person's turn ends once their decision is recorded, before the world answers what they do.
|
|
2730
|
+
async function decidePlan(session, due, scheduled, release) {
|
|
2731
|
+
let asked = session.state;
|
|
2732
|
+
const personId = due.personId;
|
|
2733
|
+
// An idle person has no due plan; they decide from their own goals and state.
|
|
2734
|
+
// How late the plan is depends on the moment the person decides; a decision made again sees it anew.
|
|
2735
|
+
// A due item that names where and with whom, such as a promise to keep, says so.
|
|
2736
|
+
const where = due.placeId !== null || due.targetId !== null ? { placeId: due.placeId, targetId: due.targetId } : {};
|
|
2737
|
+
const dueAt = (world) => !scheduled ? null : world.clock - due.at >= 30 ? { intent: due.intent, ...where, overdueMinutes: world.clock - due.at } : { intent: due.intent, ...where };
|
|
2738
|
+
let dueIntent = dueAt(asked);
|
|
2739
|
+
// A member's turn to vote, given when voting opens.
|
|
2740
|
+
const voteTurn = scheduled && due.id.startsWith('vote-');
|
|
2741
|
+
// The decision is made on the world as it is when the call goes.
|
|
2742
|
+
const build = (focusIds = []) => {
|
|
2743
|
+
asked = session.state;
|
|
2744
|
+
dueIntent = dueAt(asked);
|
|
2745
|
+
const input = characterPlanInput(asked, asked.people[personId], dueIntent, voteTurn, focusIds);
|
|
2746
|
+
return { input, responseSchema: scopedPlanResponseSchema(input) };
|
|
2747
|
+
};
|
|
2748
|
+
// The world goes on while the person decides. A decision for an activity that another event has
|
|
2749
|
+
// already replaced, or by someone who can no longer act, is set aside, and a call still waiting its
|
|
2750
|
+
// turn for it is not sent; the newer course stands.
|
|
2751
|
+
const stillDue = (world) => {
|
|
2752
|
+
const who = world.people[personId];
|
|
2753
|
+
return who !== undefined && !who.incapacitated && who.departed === undefined && who.away === undefined
|
|
2754
|
+
&& (scheduled ? world.schedule.some(item => item.id === due.id && item.sourceSeq === due.sourceSeq) : !hasPlanned(world, personId));
|
|
2755
|
+
};
|
|
2756
|
+
let answer;
|
|
2757
|
+
try {
|
|
2758
|
+
answer = parsePlan(await session.complete({ role: 'character', phase: 'plan', system: CHARACTER_PLAN_SYSTEM, ...build(), ...LIVING_INFERENCE_LIMITS.plan }, personId, null, build, () => stillDue(session.state)));
|
|
2759
|
+
}
|
|
2760
|
+
catch (error) {
|
|
2761
|
+
// The plan stays pending when the model is unavailable; a malformed answer is a real failure.
|
|
2762
|
+
if (error instanceof LivingWorldError && error.code === 'inference-unavailable')
|
|
2763
|
+
return false;
|
|
2764
|
+
if (error instanceof LivingWorldError && error.code === 'run-paused' && !session.state.paused && !stillDue(session.state))
|
|
2765
|
+
return true;
|
|
2766
|
+
throw error;
|
|
2767
|
+
}
|
|
2768
|
+
if (!stillDue(session.state))
|
|
2769
|
+
return true;
|
|
2770
|
+
// A decision that began before this person spoke or acted again, or before someone spoke to
|
|
2771
|
+
// them, answers a moment that has passed; they decide once more on what is now true.
|
|
2772
|
+
const movedOn = (since) => session.state.events.some(event => event.seq > since && (event.actorId === personId
|
|
2773
|
+
|| event.effects.some(effect => (effect.kind === 'say' || effect.kind === 'message') && effect.toIds.includes(personId))));
|
|
2774
|
+
if (answer.action !== 'wait' && movedOn(asked.revision)) {
|
|
2775
|
+
const now = session.state;
|
|
2776
|
+
let decidedAt = asked.revision;
|
|
2777
|
+
if (session.canSpend()) {
|
|
2778
|
+
decidedAt = now.revision;
|
|
2779
|
+
try {
|
|
2780
|
+
answer = parsePlan(await session.complete({
|
|
2781
|
+
role: 'character', phase: 'plan', system: CHARACTER_PLAN_SYSTEM, ...build(), ...LIVING_INFERENCE_LIMITS.plan,
|
|
2782
|
+
}, personId, null, build, () => stillDue(session.state)));
|
|
2783
|
+
}
|
|
2784
|
+
catch (error) {
|
|
2785
|
+
if (!(error instanceof LivingWorldError) || (error.code !== 'inference-invalid' && error.code !== 'inference-unavailable'))
|
|
2786
|
+
throw error;
|
|
2787
|
+
answer = { ...answer, action: 'wait', text: null };
|
|
2788
|
+
}
|
|
2789
|
+
if (!stillDue(session.state))
|
|
2790
|
+
return true;
|
|
2791
|
+
}
|
|
2792
|
+
if (answer.action !== 'wait' && movedOn(decidedAt))
|
|
2793
|
+
answer = { ...answer, action: 'wait', text: null };
|
|
2794
|
+
}
|
|
2795
|
+
if (!stillDue(session.state))
|
|
2796
|
+
return true;
|
|
2797
|
+
let state = session.state;
|
|
2798
|
+
// Words that ask again what was already asked, or that go to someone who has left this person's last
|
|
2799
|
+
// lines unanswered, are left unsaid; the rest of the decision, with its next activity, stands.
|
|
2800
|
+
const prior = answer.request?.priorId ? state.requests.find(request => request.id === answer.request.priorId) : null;
|
|
2801
|
+
const reminding = (answer.action === 'speak' || answer.action === 'message') && reminderDue(state, prior, personId, answer.targetId);
|
|
2802
|
+
const unsaid = (answer.action === 'speak' || answer.action === 'message') && answer.targetId !== null
|
|
2803
|
+
&& (prior?.receiverId === answer.targetId && !reminding || unansweredSince(state, personId, answer.targetId) !== null
|
|
2804
|
+
|| answer.text !== null && saidAlready(state, personId, answer.text));
|
|
2805
|
+
if (unsaid)
|
|
2806
|
+
answer = { ...answer, action: 'wait', text: null };
|
|
2807
|
+
// A vote cast in this decision is its own act, seen by those present, before whatever else the person does.
|
|
2808
|
+
const voted = voteTurn ? ballotEffects(session.state, personId, answer.ballot, 'any') : [];
|
|
2809
|
+
if (voted[0]?.kind === 'ballot') {
|
|
2810
|
+
const voter = session.state.people[personId];
|
|
2811
|
+
session.commit({ kind: 'reaction', actorId: personId, placeId: voter.placeId, summary: `${voter.name} votes ${voted[0].choice} on ${session.state.entities[voted[0].entityId].name}`.slice(0, 2000),
|
|
2812
|
+
narration: null, outcome: null, minutes: 0, effects: voted, causeSeq: due.sourceSeq || null, importance: 3, suggestions: [], ...quiet });
|
|
2813
|
+
}
|
|
2814
|
+
state = session.state;
|
|
2815
|
+
const person = state.people[personId];
|
|
2816
|
+
const seq = state.revision + 1;
|
|
2817
|
+
const live = session.live();
|
|
2818
|
+
// A plan that waits twice for itself ends here. In a live world a plan is an activity, and
|
|
2819
|
+
// a decision that names none keeps the person at what they were doing a while longer.
|
|
2820
|
+
const again = !live && (state.engine >= BOUNDED_IDENTIFIERS_ENGINE ? due.replanned === true : due.id.endsWith('-again'));
|
|
2821
|
+
const stale = !live && answer.nextPlan !== null && answer.action === 'wait';
|
|
2822
|
+
if (stale && again)
|
|
2823
|
+
answer.nextPlan = null;
|
|
2824
|
+
// Someone another person there holds, by hand or by a typed gate, cannot walk away; a gate on speech,
|
|
2825
|
+
// messages or acts turns that plan into waiting.
|
|
2826
|
+
const kept = heldHere(state, person.id) || blockedByGate(state, person.id, 'move') !== null;
|
|
2827
|
+
if (kept && (answer.action === 'go' || answer.action === 'go-home')) {
|
|
2828
|
+
answer.action = 'wait';
|
|
2829
|
+
answer.text = null;
|
|
2830
|
+
}
|
|
2831
|
+
// Someone back from home stays at least two hours before going home again; they carry on with the day.
|
|
2832
|
+
if (answer.action === 'go-home' && person.restedAt !== undefined && state.clock - person.restedAt < DAY_AWAY_MINUTES) {
|
|
2833
|
+
answer = { ...answer, action: 'wait', text: null, nextPlan: null };
|
|
2834
|
+
}
|
|
2835
|
+
const gatedPlan = answer.action === 'speak' ? 'speak' : answer.action === 'message' ? 'message' : answer.action === 'attempt' ? 'act' : null;
|
|
2836
|
+
if (gatedPlan !== null && blockedByGate(state, person.id, gatedPlan) !== null) {
|
|
2837
|
+
answer.action = 'wait';
|
|
2838
|
+
answer.text = null;
|
|
2839
|
+
}
|
|
2840
|
+
const valid = answer.nextPlan && (answer.nextPlan.placeId === null || state.places[answer.nextPlan.placeId]) && (answer.nextPlan.targetId === null || state.people[answer.nextPlan.targetId]) ? answer.nextPlan : null;
|
|
2841
|
+
// Someone whose words were held back waits for an answer, not for their next try at saying them.
|
|
2842
|
+
const named = valid && unsaid ? { ...valid, targetId: null, inMinutes: Math.max(valid.inMinutes, DEFAULT_ACTIVITY_MINUTES) } : valid;
|
|
2843
|
+
const next = named ?? (live ? { intent: person.plan?.intent ?? 'Carry on with the day', ...(person.plan?.seen ? { seen: person.plan.seen } : {}),
|
|
2844
|
+
placeId: null, targetId: null, inMinutes: DEFAULT_ACTIVITY_MINUTES } : null);
|
|
2845
|
+
const remaining = { ...state, schedule: state.schedule.filter(item => item.id !== due.id) };
|
|
2846
|
+
const mood = answer.mood;
|
|
2847
|
+
// A due decision about someone's serious act records where this person now stands.
|
|
2848
|
+
const standing = isDecision(due) && answer.stance !== null
|
|
2849
|
+
? state.stances.find(stance => stance.personId === person.id && stance.actorId === due.targetId) : undefined;
|
|
2850
|
+
const base = [
|
|
2851
|
+
...(scheduled ? [{ kind: 'unschedule', scheduledId: due.id }] : []),
|
|
2852
|
+
...(mood !== person.mood ? [{ kind: 'mood', personId: person.id, mood }] : []),
|
|
2853
|
+
...(standing && answer.stance !== null ? [{ kind: 'stance', personId: person.id, actorId: standing.actorId, status: answer.stance, sourceSeq: standing.sourceSeq }] : []),
|
|
2854
|
+
];
|
|
2855
|
+
const newRequest = answer.request && answer.request.priorId === null && answer.targetId && answer.text
|
|
2856
|
+
&& (answer.action === 'speak' || answer.action === 'message')
|
|
2857
|
+
? [{ kind: 'request.open', requestId: generatedId(session.state.engine, 'request', [seq, person.id]), senderId: person.id,
|
|
2858
|
+
receiverId: answer.targetId, topic: answer.request.topic }]
|
|
2859
|
+
: reminding && !unsaid && prior ? [{ kind: 'request.remind', requestId: prior.id }] : [];
|
|
2860
|
+
const follow = next ? planEffects(remaining, person.id, next, seq, stale ? '-again' : '') : [{ kind: 'plan', personId: person.id, plan: null }];
|
|
2861
|
+
const cause = due.sourceSeq || null;
|
|
2862
|
+
const target = answer.targetId && state.people[answer.targetId] ? state.people[answer.targetId] : null;
|
|
2863
|
+
// A next activity somewhere else begins with the walk there, so nobody stands for hours getting ready to leave.
|
|
2864
|
+
if (!kept && answer.action === 'wait' && named?.placeId && named.placeId !== person.placeId && state.places[named.placeId]) {
|
|
2865
|
+
answer = { ...answer, action: 'go', placeId: named.placeId };
|
|
2866
|
+
}
|
|
2867
|
+
const wantsToTravel = !kept && (answer.action === 'go' ? answer.placeId !== null && state.places[answer.placeId] !== undefined && answer.placeId !== person.placeId
|
|
2868
|
+
: answer.action === 'speak' && target !== null && target.placeId !== person.placeId);
|
|
2869
|
+
if (answer.action === 'attempt' && answer.text) {
|
|
2870
|
+
// A character's attempt goes through the same adjudication as the player's.
|
|
2871
|
+
session.commit({ kind: 'plan', actorId: person.id, placeId: person.placeId, summary: `${person.name} decides: ${answer.text}`.slice(0, 2000), narration: null, outcome: null, minutes: 0, effects: [...base, ...follow], causeSeq: cause, importance: 2, suggestions: [], ...quiet });
|
|
2872
|
+
release();
|
|
2873
|
+
try {
|
|
2874
|
+
await adjudicate(session, person.id, answer.text);
|
|
2875
|
+
}
|
|
2876
|
+
catch (error) {
|
|
2877
|
+
if (!(error instanceof LivingWorldError) || (error.code !== 'inference-invalid' && error.code !== 'inference-unavailable'))
|
|
2878
|
+
throw error;
|
|
2879
|
+
}
|
|
2880
|
+
return true;
|
|
2881
|
+
}
|
|
2882
|
+
// Going home takes the person off the map, to rest, sleep or an errand, until their next activity ends.
|
|
2883
|
+
if (answer.action === 'go-home') {
|
|
2884
|
+
const reason = (answer.text ?? named?.intent ?? 'Away for a while').slice(0, MAX_LINE);
|
|
2885
|
+
// A visitor brought in mid-story who goes home has left it; anyone else is home at least the night.
|
|
2886
|
+
if (person.newcomer) {
|
|
2887
|
+
session.commit({ kind: 'move', actorId: person.id, placeId: person.placeId, summary: `${person.name} leaves: ${reason}`.slice(0, 2000), narration: null, outcome: null, minutes: 0,
|
|
2888
|
+
effects: [...base, { kind: 'person.leave', personId: person.id, reason }], causeSeq: cause, importance: 2, suggestions: [], ...quiet, observed: `${person.name} leaves.` });
|
|
2889
|
+
return true;
|
|
2890
|
+
}
|
|
2891
|
+
// Home until the night is over, or until in time for a promise that falls due sooner.
|
|
2892
|
+
const promised = person.commitments.filter(commitment => commitment.status === 'open' && commitment.dueAt !== null && commitment.dueAt > state.clock)
|
|
2893
|
+
.map(commitment => commitment.dueAt - state.clock - PROMISE_LEAD_MINUTES);
|
|
2894
|
+
const night = isNight(state.clock) || state.clock - (person.restedAt ?? state.startClock ?? 0) >= LONG_DAY_MINUTES;
|
|
2895
|
+
const least = night ? GO_HOME_MINUTES : DAY_AWAY_MINUTES;
|
|
2896
|
+
const minutes = Math.max(AWAY_MINUTES.min, Math.min(Math.max(least, Math.min(AWAY_MINUTES.max, named?.inMinutes ?? least)), ...promised));
|
|
2897
|
+
session.commit({ kind: 'move', actorId: person.id, placeId: person.placeId, summary: `${person.name} goes home: ${reason}`.slice(0, 2000), narration: null, outcome: null, minutes: 0,
|
|
2898
|
+
effects: [...base, { kind: 'person.away', personId: person.id, minutes, reason }], causeSeq: cause, importance: 2, suggestions: [], ...quiet, observed: `${person.name} leaves.` });
|
|
2899
|
+
return true;
|
|
2900
|
+
}
|
|
2901
|
+
if (wantsToTravel) {
|
|
2902
|
+
const destination = answer.action === 'go' ? answer.placeId : target.placeId;
|
|
2903
|
+
const path = travelPath(state, person.placeId, destination);
|
|
2904
|
+
if (!path) {
|
|
2905
|
+
session.commit({ kind: 'plan', actorId: person.id, placeId: person.placeId, summary: `${person.name} cannot reach ${state.places[destination].name}`, narration: null, outcome: null, minutes: 0, effects: [...base, ...follow], causeSeq: cause, importance: 1, suggestions: [], ...quiet });
|
|
2906
|
+
return true;
|
|
2907
|
+
}
|
|
2908
|
+
// Words for someone elsewhere are chosen on arrival, from what is there then.
|
|
2909
|
+
const arrival = answer.action === 'speak' && answer.text
|
|
2910
|
+
? planEffects(remaining, person.id, { intent: `Say to ${target.name} what you came to say: ${answer.text}`.slice(0, 300), seen: `on the way to ${target.name}`,
|
|
2911
|
+
placeId: destination, targetId: target.id, inMinutes: (path.length - 1) * TRAVEL_MINUTES_PER_HOP }, seq)
|
|
2912
|
+
: follow;
|
|
2913
|
+
session.commit({
|
|
2914
|
+
kind: 'move', actorId: person.id, placeId: person.placeId, summary: `${person.name} travels to ${state.places[destination].name}`, narration: null, outcome: null, minutes: (path.length - 1) * TRAVEL_MINUTES_PER_HOP,
|
|
2915
|
+
observed: `${person.name} leaves toward ${firstRoom(state, path)}.`,
|
|
2916
|
+
effects: [...base, { kind: 'move', personId: person.id, placeId: destination }, ...arrival], causeSeq: cause, importance: 2, suggestions: [], ...quiet,
|
|
2917
|
+
});
|
|
2918
|
+
return true;
|
|
2919
|
+
}
|
|
2920
|
+
if (answer.action === 'speak' && answer.text && target && isHere(state, target.id, person.placeId)) {
|
|
2921
|
+
const event = session.commit({
|
|
2922
|
+
kind: 'reaction', actorId: person.id, placeId: person.placeId, summary: `${person.name} says to ${target.name}: "${answer.text}"`.slice(0, 2000), narration: null, outcome: null, minutes: SPEECH_MINUTES,
|
|
2923
|
+
effects: [...base, { kind: 'say', personId: person.id, text: answer.text, toIds: [target.id] }, ...newRequest, ...follow], causeSeq: cause, importance: 3, suggestions: [], ...quiet,
|
|
2924
|
+
});
|
|
2925
|
+
release();
|
|
2926
|
+
await replyBetween(session, person.id, target.id, event, answer.text);
|
|
2927
|
+
return true;
|
|
2928
|
+
}
|
|
2929
|
+
if (answer.action === 'message' && answer.text && target) {
|
|
2930
|
+
const travel = state.messageTravel;
|
|
2931
|
+
const opened = newRequest[0]?.kind === 'request.open' ? { requestId: newRequest[0].requestId, topic: newRequest[0].topic } : undefined;
|
|
2932
|
+
session.commit({
|
|
2933
|
+
kind: 'message', actorId: person.id, placeId: person.placeId, summary: `${person.name} sends a message to ${target.name}${travel ? ` ${travel.how}` : ''}`, narration: null, outcome: null, minutes: MESSAGE_MINUTES,
|
|
2934
|
+
effects: travel ? [...base, dispatch(seq, 0, { fromId: person.id, toIds: [target.id], text: answer.text, replies: true, request: opened }),
|
|
2935
|
+
...newRequest.filter(effect => effect.kind === 'request.remind'), ...follow] : [
|
|
2936
|
+
...base, { kind: 'message', fromId: person.id, toIds: [target.id], text: answer.text }, ...newRequest,
|
|
2937
|
+
...(target.isPlayer ? [] : [{ kind: 'schedule', scheduledId: generatedId(session.state.engine, 'reply', [seq, target.id]), personId: target.id, inMinutes: MESSAGE_REPLY_DELAY_MINUTES, intent: answer.text, placeId: null, targetId: person.id, scheduleKind: 'reply' }]),
|
|
2938
|
+
...follow,
|
|
2939
|
+
],
|
|
2940
|
+
causeSeq: cause, importance: 3, suggestions: [], ...quiet,
|
|
2941
|
+
});
|
|
2942
|
+
return true;
|
|
2943
|
+
}
|
|
2944
|
+
session.commit({
|
|
2945
|
+
kind: 'plan', actorId: person.id, placeId: person.placeId, summary: next ? `${person.name} makes a plan: ${next.intent}`.slice(0, 2000) : dueIntent?.overdueMinutes === undefined ? `${person.name} waits` : `${person.name} drops the overdue plan and waits`, narration: null, outcome: null, minutes: 0,
|
|
2946
|
+
effects: [...base, ...follow], causeSeq: cause, importance: 1, suggestions: [], ...quiet,
|
|
2947
|
+
// What onlookers see, as a label beside the name, not a sentence the free text must finish.
|
|
2948
|
+
...(next?.seen ? { observed: `${person.name}: ${next.seen}`.slice(0, 600) } : {}),
|
|
2949
|
+
...(unsaid ? { droppedEffects: 1 } : {}),
|
|
2950
|
+
});
|
|
2951
|
+
return true;
|
|
2952
|
+
}
|
|
2953
|
+
async function answerMessage(session, due) {
|
|
2954
|
+
const release = await session.takeTurn(due.personId);
|
|
2955
|
+
try {
|
|
2956
|
+
return await replyToMessage(session, due);
|
|
2957
|
+
}
|
|
2958
|
+
finally {
|
|
2959
|
+
release();
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
async function replyToMessage(session, due) {
|
|
2963
|
+
let state = session.state;
|
|
2964
|
+
const person = state.people[due.personId];
|
|
2965
|
+
const fromId = due.targetId ?? state.playerId;
|
|
2966
|
+
const answeredInPerson = state.events.some(event => event.causeSeq === due.sourceSeq
|
|
2967
|
+
&& event.effects.some(effect => effect.kind === 'say' && effect.personId === person.id && effect.toIds.includes(fromId)));
|
|
2968
|
+
if (answeredInPerson) {
|
|
2969
|
+
session.commit({
|
|
2970
|
+
kind: 'message', actorId: person.id, placeId: person.placeId, summary: `${person.name} sets ${personLabel(state, fromId)}'s message aside; it was answered in person`,
|
|
2971
|
+
narration: null, outcome: null, minutes: 0, effects: [{ kind: 'unschedule', scheduledId: due.id }], causeSeq: due.sourceSeq || null, importance: 1, suggestions: [], ...quiet,
|
|
2972
|
+
});
|
|
2973
|
+
return true;
|
|
2974
|
+
}
|
|
2975
|
+
let value;
|
|
2976
|
+
const build = (focusIds = []) => ({ input: characterMessageReplyInput(session.state, session.state.people[person.id], { fromId, text: due.intent }, focusIds), responseSchema: messageReplyResponseSchema });
|
|
2977
|
+
try {
|
|
2978
|
+
value = await session.complete({ role: 'character', phase: 'message-reply', system: CHARACTER_MESSAGE_SYSTEM, ...build(), ...LIVING_INFERENCE_LIMITS['message-reply'] }, person.id, due.sourceSeq, build);
|
|
2979
|
+
}
|
|
2980
|
+
catch (error) {
|
|
2981
|
+
if (error instanceof LivingWorldError && error.code === 'inference-unavailable')
|
|
2982
|
+
return false;
|
|
2983
|
+
throw error;
|
|
2984
|
+
}
|
|
2985
|
+
if (!value || typeof value !== 'object' || typeof value.mood !== 'string')
|
|
2986
|
+
throw new LivingWorldError('inference-invalid', 'The message answer is malformed');
|
|
2987
|
+
// The world went on while they wrote: someone struck down or gone meanwhile sends nothing, and a
|
|
2988
|
+
// reply already handled is not handled twice.
|
|
2989
|
+
const writer = session.state.people[person.id];
|
|
2990
|
+
if (!writer || writer.incapacitated || writer.departed || !session.state.schedule.some(item => item.id === due.id))
|
|
2991
|
+
return true;
|
|
2992
|
+
state = session.state;
|
|
2993
|
+
const raw = typeof value.reply === 'string' && value.reply.trim() !== '' ? value.reply.trim().slice(0, MAX_SPOKEN) : null;
|
|
2994
|
+
const reply = raw;
|
|
2995
|
+
const belief = value.belief && typeof value.belief === 'object' ? value.belief : null;
|
|
2996
|
+
const plan = parsePlanValue(value.plan, 30);
|
|
2997
|
+
const trust = clampInt(value.trust, -20, 20, 0);
|
|
2998
|
+
const warmth = clampInt(value.warmth, -20, 20, 0);
|
|
2999
|
+
const seq = state.revision + 1;
|
|
3000
|
+
const mood = cleanMood(value.mood, writer.mood);
|
|
3001
|
+
const effects = [
|
|
3002
|
+
{ kind: 'unschedule', scheduledId: due.id },
|
|
3003
|
+
...(mood !== writer.mood ? [{ kind: 'mood', personId: person.id, mood }] : []),
|
|
3004
|
+
...(reply && blockedByGate(state, person.id, 'message') === null ? [state.messageTravel ? dispatch(seq, 0, { fromId: person.id, toIds: [fromId], text: reply, replies: false })
|
|
3005
|
+
: { kind: 'message', fromId: person.id, toIds: [fromId], text: reply }] : []),
|
|
3006
|
+
...((trust !== 0 || warmth !== 0) && fromId !== person.id && state.people[fromId] ? [{ kind: 'relationship', personId: person.id, targetId: fromId, trust, warmth, note: '' }] : []),
|
|
3007
|
+
...(belief && typeof belief.subject === 'string' && typeof belief.claim === 'string' && belief.subject.trim() !== '' && belief.claim.trim() !== '' ? [{ kind: 'belief', personId: person.id, subject: belief.subject.trim().slice(0, 120), claim: belief.claim.trim().slice(0, 300), sourcePersonId: state.people[fromId] ? fromId : null,
|
|
3008
|
+
evidence: { kind: 'told', eventSeq: due.sourceSeq, sourceId: fromId } }] : []),
|
|
3009
|
+
// Someone answering from home decides what to do next once back.
|
|
3010
|
+
...(plan && !writer.away && (plan.placeId === null || state.places[plan.placeId]) && (plan.targetId === null || state.people[plan.targetId]) ? planEffects({ ...state, schedule: state.schedule.filter(item => item.id !== due.id) }, person.id, plan, seq) : []),
|
|
3011
|
+
];
|
|
3012
|
+
session.commit({
|
|
3013
|
+
kind: 'message', actorId: person.id, placeId: person.placeId,
|
|
3014
|
+
summary: reply ? `${person.name} replies to ${personLabel(state, fromId)}'s message` : `${person.name} reads ${personLabel(state, fromId)}'s message and does not reply`,
|
|
3015
|
+
narration: null, outcome: null, minutes: 0, effects, causeSeq: due.sourceSeq || null, importance: 3, suggestions: [], ...quiet,
|
|
3016
|
+
// No one where they left sees someone at home write.
|
|
3017
|
+
...(writer.away ? { perceivedBy: [] } : {}),
|
|
3018
|
+
});
|
|
3019
|
+
return true;
|
|
3020
|
+
}
|