@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,1853 @@
|
|
|
1
|
+
import { BOUNDED_IDENTIFIERS_ENGINE, generatedId } from './identifiers.js';
|
|
2
|
+
import { current, isDraft, produce, setAutoFreeze } from 'immer';
|
|
3
|
+
import { observedObjectIds, observedSummary, visibleTo, witnessed } from './projection.js';
|
|
4
|
+
import { valueMatchesType } from './open-world.js';
|
|
5
|
+
import { ACT_GATE_ENGINE, GLOBAL_SIGNAL_CHANNEL, LAZY_METER_ENGINE, LOCAL_RECOVERY_ENGINE, plain, PEEK_ENTITY, readEntity, advanceScopedEntities, applyEventChanges, currentValue, dropStalePredictions, indexPlaceless, insertScheduled, reindexPlaceless, keepPassedCrossings, predictCrossings, blockedByGate, gatesAgainst, heldHere, normViolationsFor, runTriggers, signalNotice, signalsFor, spreadLevel } from './mechanics.js';
|
|
6
|
+
import { STABLE_CONVERSATION_ENGINE, ADDED_LINE_ENGINE, AUTHORS_ENGINE, AVERTED_ENGINE, AWAY_REPLY_ENGINE, SEEN_AT_EVENTS_ENGINE, THINGS_SEEN_ENGINE, VOTE_CLOSES_ENGINE, VOTE_OPEN_MINUTES, isNight, COUNT_ONLY_ENGINE, REMINDER_ENGINE, WHOLE_SIDES_ENGINE, PLAYER_REST_ENGINE, SIDE_ORDER_ENGINE, OPEN_NOW_ENGINE, SEEN_WHERE_ENGINE, REST_ENGINE, UNHELD_ENGINE, WHOLE_TEXT_ENGINE, AWAY_APART_ENGINE, CHRONICLE_ENGINE, CONDITION_SIDES_ENGINE, DEADLINE_ENGINE, LEFT_UNSEEN_ENGINE, MAX_ADDED_LINE, VOTES_ENGINE, MAX_LINE, PROMISE_IMPORTANCE, PROMISE_LEAD_MINUTES, IDENTITY_ENGINE, STALL_ENGINE, STALL_MINUTES, SILENCE_ENGINE, WRITING_GROWS_ENGINE, MAX_TEXT, clockLabel, UNANSWERED_LINES, UNANSWERED_MINUTES, UNANSWERED_RESET_MINUTES, NEWCOMER_ENGINE, REQUEST_TIME_ENGINE, LivingWorldError, MAX_CHRONICLE, MAX_THING_NOTES, THING_NOTE_TEXT, SHOWN_CLOCK_ENGINE, THING_MEMORY_ENGINE, WALK_OFF_ENGINE, entityRefs, eventSchema, sealLivingIndexes, GONE_AT_ENGINE, PROBLEM_VIEW_ENGINE } from './types.js';
|
|
7
|
+
import { BOUNDED_PERSON_RECORDS_ENGINE, MAX_WORKING_BELIEFS, MAX_CLOSED_COMMITMENTS, MAX_OPEN_COMMITMENTS } from './types.js';
|
|
8
|
+
import { namesPerson, neverHeld, oneEachSlot, sealedPaper } from './shared-kinds.js';
|
|
9
|
+
// The reducer is the replay oracle. It is pure: the same state and event always give
|
|
10
|
+
// the same next state, and nothing here consults a model, a clock, or randomness.
|
|
11
|
+
setAutoFreeze(false);
|
|
12
|
+
class EntityOverlay {
|
|
13
|
+
}
|
|
14
|
+
function entityOverlay(entities) {
|
|
15
|
+
const changed = {};
|
|
16
|
+
const table = new Proxy(new EntityOverlay(), {
|
|
17
|
+
get(target, key) {
|
|
18
|
+
if (key === PEEK_ENTITY)
|
|
19
|
+
return (id) => Object.hasOwn(changed, id) ? changed[id] : entities[id];
|
|
20
|
+
if (typeof key !== 'string')
|
|
21
|
+
return Reflect.get(target, key);
|
|
22
|
+
if (Object.hasOwn(changed, key))
|
|
23
|
+
return changed[key];
|
|
24
|
+
if (!Object.hasOwn(entities, key))
|
|
25
|
+
return Reflect.get(target, key);
|
|
26
|
+
const original = entities[key];
|
|
27
|
+
const copied = structuredClone(original);
|
|
28
|
+
changed[key] = copied;
|
|
29
|
+
return copied;
|
|
30
|
+
},
|
|
31
|
+
set(_target, key, value) {
|
|
32
|
+
if (typeof key !== 'string')
|
|
33
|
+
return false;
|
|
34
|
+
changed[key] = value;
|
|
35
|
+
return true;
|
|
36
|
+
},
|
|
37
|
+
ownKeys() { return [...new Set([...Object.keys(entities), ...Object.keys(changed)])]; },
|
|
38
|
+
getOwnPropertyDescriptor(_target, key) {
|
|
39
|
+
if (typeof key !== 'string' || !Object.hasOwn(changed, key) && !Object.hasOwn(entities, key))
|
|
40
|
+
return undefined;
|
|
41
|
+
return { configurable: true, enumerable: true, writable: true, value: changed[key] ?? entities[key] };
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
return { table, changes: () => changed };
|
|
45
|
+
}
|
|
46
|
+
// Who is in a place now; someone who has left the world, or is away for a while, is nowhere.
|
|
47
|
+
export function peopleAt(state, placeId) {
|
|
48
|
+
return Object.values(state.people).filter(person => person.placeId === placeId && person.departed === undefined && person.away === undefined).map(person => person.id).sort();
|
|
49
|
+
}
|
|
50
|
+
export function witnessesFor(state, event) {
|
|
51
|
+
const present = event.placeId === null ? [] : peopleAt(state, event.placeId);
|
|
52
|
+
if (event.actorId !== null && !present.includes(event.actorId))
|
|
53
|
+
return [...present, event.actorId].sort();
|
|
54
|
+
return present;
|
|
55
|
+
}
|
|
56
|
+
// Who notices an event without being in the room: with 'nearby' reach, everyone in a
|
|
57
|
+
// connected place; with 'everywhere', everyone elsewhere; and whoever is in a room that
|
|
58
|
+
// someone walks into sees them arrive. Derived from the place graph, so replay can
|
|
59
|
+
// check it the way it checks witnesses.
|
|
60
|
+
export function nearbyFor(state, event) {
|
|
61
|
+
const noticed = new Set();
|
|
62
|
+
const place = event.placeId === null ? undefined : state.places[event.placeId];
|
|
63
|
+
if (place && (event.reach ?? 'here') !== 'here') {
|
|
64
|
+
const places = event.reach === 'everywhere' ? Object.keys(state.places).filter(id => id !== event.placeId) : place.connections;
|
|
65
|
+
for (const id of places.flatMap(id => peopleAt(state, id)))
|
|
66
|
+
noticed.add(id);
|
|
67
|
+
}
|
|
68
|
+
for (const effect of event.effects) {
|
|
69
|
+
if (effect.kind !== 'move')
|
|
70
|
+
continue;
|
|
71
|
+
for (const id of peopleAt(state, effect.placeId))
|
|
72
|
+
if (id !== effect.personId)
|
|
73
|
+
noticed.add(id);
|
|
74
|
+
}
|
|
75
|
+
for (const signal of signalsFor(state, event.effects)) {
|
|
76
|
+
if (signal.targetPlaceId !== null)
|
|
77
|
+
for (const id of peopleAt(state, signal.targetPlaceId))
|
|
78
|
+
noticed.add(id);
|
|
79
|
+
else if (signal.channel === GLOBAL_SIGNAL_CHANNEL)
|
|
80
|
+
for (const id of Object.keys(state.people))
|
|
81
|
+
noticed.add(id);
|
|
82
|
+
}
|
|
83
|
+
return [...noticed].filter(id => event.placeId === null || state.people[id].placeId !== event.placeId).sort();
|
|
84
|
+
}
|
|
85
|
+
// Where someone arrived from, when an event walked them into this person's room.
|
|
86
|
+
export function arrivalFor(state, event, personId) {
|
|
87
|
+
const person = state.people[personId];
|
|
88
|
+
if (!person)
|
|
89
|
+
return null;
|
|
90
|
+
const move = event.effects.find(effect => effect.kind === 'move' && effect.placeId === person.placeId && effect.personId !== personId);
|
|
91
|
+
if (!move || move.kind !== 'move')
|
|
92
|
+
return null;
|
|
93
|
+
return `${state.people[move.personId]?.name ?? move.personId} comes in from ${event.placeId ? state.places[event.placeId]?.name ?? event.placeId : 'elsewhere'}`;
|
|
94
|
+
}
|
|
95
|
+
// The working state keeps the recent part of what the event log keeps whole: the latest lines of
|
|
96
|
+
// each conversation, the latest ended conversations, and the latest messages.
|
|
97
|
+
const CONVERSATION_LINES = 64;
|
|
98
|
+
const ENDED_CONVERSATIONS = 16;
|
|
99
|
+
const INBOX_MESSAGES = 256;
|
|
100
|
+
const KEPT_CLOSED_REQUESTS = 64;
|
|
101
|
+
const BOUNDED_RECORDS_ENGINE = 33;
|
|
102
|
+
// How many of the newest events, and their changes, the working state carries.
|
|
103
|
+
const RECENT_EVENTS = 64;
|
|
104
|
+
const KEPT_ACTIVE_GOALS = 32;
|
|
105
|
+
const KEPT_FINISHED_GOALS = 16;
|
|
106
|
+
// The working state carries the property and relation changes of the recent events it carries;
|
|
107
|
+
// the durable change log keeps every change. Dropping from the front costs each change once.
|
|
108
|
+
function keepRecentChanges(state, cutoffSeq) {
|
|
109
|
+
const items = plain(state.changes);
|
|
110
|
+
let first = 0;
|
|
111
|
+
while (first < items.length && items[first].seq <= cutoffSeq)
|
|
112
|
+
first += 1;
|
|
113
|
+
if (first === 0)
|
|
114
|
+
return;
|
|
115
|
+
state.changes = items.slice(first);
|
|
116
|
+
}
|
|
117
|
+
function keepWorkingStateBounded(state, replay) {
|
|
118
|
+
for (const conversation of state.conversations) {
|
|
119
|
+
if (conversation.lines.length > CONVERSATION_LINES)
|
|
120
|
+
conversation.lines = conversation.lines.slice(-CONVERSATION_LINES);
|
|
121
|
+
}
|
|
122
|
+
const ended = state.conversations.filter(conversation => conversation.endedAt !== null);
|
|
123
|
+
if (ended.length > ENDED_CONVERSATIONS) {
|
|
124
|
+
const dropped = new Set(ended.slice(0, ended.length - ENDED_CONVERSATIONS).map(conversation => conversation.id));
|
|
125
|
+
state.conversations = state.conversations.filter(conversation => !dropped.has(conversation.id));
|
|
126
|
+
}
|
|
127
|
+
if (state.messages.length > INBOX_MESSAGES)
|
|
128
|
+
state.messages = state.messages.slice(-INBOX_MESSAGES);
|
|
129
|
+
// Requests and goals stay in the event log; saved state keeps what inputs and rules can use: every
|
|
130
|
+
// open request, the newest refusals, the newest settled requests, and each person's newest goals.
|
|
131
|
+
// Older engines kept every request and goal; their logged ids depend on that, so they keep it.
|
|
132
|
+
if (state.engine < BOUNDED_RECORDS_ENGINE)
|
|
133
|
+
return;
|
|
134
|
+
const newest = (items, keep) => new Set(items.slice(Math.max(0, items.length - keep)));
|
|
135
|
+
if (state.requests.length > KEPT_CLOSED_REQUESTS) {
|
|
136
|
+
const refused = newest(state.requests.filter(request => request.status === 'refused'), KEPT_CLOSED_REQUESTS);
|
|
137
|
+
const settled = newest(state.requests.filter(request => request.status === 'answered' || request.status === 'done'), KEPT_CLOSED_REQUESTS);
|
|
138
|
+
if (refused.size + settled.size + state.requests.filter(request => request.status === 'open').length < state.requests.length) {
|
|
139
|
+
state.requests = state.requests.filter(request => request.status === 'open' || refused.has(request) || settled.has(request));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const person of Object.values(state.people)) {
|
|
143
|
+
if (state.engine >= BOUNDED_PERSON_RECORDS_ENGINE) {
|
|
144
|
+
if (person.beliefs.length > MAX_WORKING_BELIEFS)
|
|
145
|
+
person.beliefs = person.beliefs.slice(-MAX_WORKING_BELIEFS);
|
|
146
|
+
const closed = newest(person.commitments.filter(item => item.status !== 'open'), MAX_CLOSED_COMMITMENTS);
|
|
147
|
+
if (closed.size + person.commitments.filter(item => item.status === 'open').length < person.commitments.length) {
|
|
148
|
+
person.commitments = person.commitments.filter(item => item.status === 'open' || closed.has(item));
|
|
149
|
+
if (replay)
|
|
150
|
+
replay.set(person.id, new Map(person.commitments.map(item => [item.id, item])));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (person.goals.length <= KEPT_ACTIVE_GOALS)
|
|
154
|
+
continue;
|
|
155
|
+
const active = newest(person.goals.filter(goal => goal.status === 'active'), KEPT_ACTIVE_GOALS);
|
|
156
|
+
const finished = newest(person.goals.filter(goal => goal.status !== 'active'), KEPT_FINISHED_GOALS);
|
|
157
|
+
if (active.size + finished.size < person.goals.length)
|
|
158
|
+
person.goals = person.goals.filter(goal => active.has(goal) || finished.has(goal));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export function applyLivingEvent(previous, input) {
|
|
162
|
+
return applyEvent(previous, input);
|
|
163
|
+
}
|
|
164
|
+
// Replay owns this fresh state. Mutating it avoids copying the full archive on every event.
|
|
165
|
+
export function foldLivingEvents(initial, events) {
|
|
166
|
+
let state = structuredClone(initial);
|
|
167
|
+
const commitments = new Map(Object.values(state.people).map(person => [person.id, new Map(person.commitments.map(commitment => [commitment.id, commitment]))]));
|
|
168
|
+
for (const event of events)
|
|
169
|
+
state = applyEvent(state, event, commitments);
|
|
170
|
+
return sealLivingIndexes(state);
|
|
171
|
+
}
|
|
172
|
+
function applyEvent(previous, input, replay) {
|
|
173
|
+
if (previous.status === 'finished')
|
|
174
|
+
throw new LivingWorldError('run-finished', 'A finished story accepts no further events');
|
|
175
|
+
// Events written by an older engine carry no reach: parsing fills the defaults and the
|
|
176
|
+
// reducer works out who was nearby, since that record was never kept.
|
|
177
|
+
const legacy = input.reach === undefined;
|
|
178
|
+
const parsed = eventSchema.safeParse(input);
|
|
179
|
+
if (!parsed.success)
|
|
180
|
+
throw new LivingWorldError('state-invalid', `Event ${input.seq} is malformed: ${parsed.error.message}`);
|
|
181
|
+
const signaledNotice = signalNotice(previous, parsed.data.effects, parsed.data.observed ?? null);
|
|
182
|
+
const event = legacy
|
|
183
|
+
? { ...parsed.data, nearbyIds: nearbyFor(previous, parsed.data), notice: signaledNotice ?? parsed.data.notice }
|
|
184
|
+
: { ...parsed.data, notice: signaledNotice ?? parsed.data.notice };
|
|
185
|
+
if (event.seq !== previous.revision + 1)
|
|
186
|
+
throw new LivingWorldError('replay-mismatch', `Event ${event.seq} does not follow revision ${previous.revision}`);
|
|
187
|
+
if (event.at !== previous.clock)
|
|
188
|
+
throw new LivingWorldError('replay-mismatch', `Event ${event.seq} starts at ${event.at}, not the clock ${previous.clock}`);
|
|
189
|
+
// A paused world accepts nothing but the clock event that resumes it.
|
|
190
|
+
if (previous.paused && event.kind !== 'clock')
|
|
191
|
+
throw new LivingWorldError('run-paused', 'The world is paused');
|
|
192
|
+
checkClockEvent(previous, event);
|
|
193
|
+
// In a live world only the clock moves time; an act's minutes are how long it keeps its actor busy.
|
|
194
|
+
const live = previous.clockRate !== null;
|
|
195
|
+
const endsAt = live && event.kind !== 'tick' ? event.at : event.at + event.minutes;
|
|
196
|
+
const expectedWitnesses = witnessesFor(previous, event);
|
|
197
|
+
if (JSON.stringify(expectedWitnesses) !== JSON.stringify([...event.witnessIds].sort())) {
|
|
198
|
+
throw new LivingWorldError('replay-mismatch', `Event ${event.seq} names witnesses ${event.witnessIds.join(',')} but ${expectedWitnesses.join(',')} were present`);
|
|
199
|
+
}
|
|
200
|
+
if (event.perceivedBy && (new Set(event.perceivedBy).size !== event.perceivedBy.length
|
|
201
|
+
|| event.perceivedBy.some(id => !expectedWitnesses.includes(id)))) {
|
|
202
|
+
throw new LivingWorldError('effect-invalid', `Event ${event.seq} names someone who could not perceive it`);
|
|
203
|
+
}
|
|
204
|
+
const affectedObjects = new Set(event.effects.flatMap(effect => effect.kind === 'object.update' || effect.kind === 'object.create' ? [effect.objectId] : []));
|
|
205
|
+
if (event.visibleObjectIds && new Set(event.visibleObjectIds).size !== event.visibleObjectIds.length) {
|
|
206
|
+
throw new LivingWorldError('effect-invalid', `Event ${event.seq} repeats a visible object id`);
|
|
207
|
+
}
|
|
208
|
+
const remoteVisibleObject = event.visibleObjectIds?.find(id => {
|
|
209
|
+
if (affectedObjects.has(id))
|
|
210
|
+
return false;
|
|
211
|
+
const object = previous.objects[id];
|
|
212
|
+
return !object || event.placeId === null || object.placeId !== event.placeId
|
|
213
|
+
&& (object.holderId === null || previous.people[object.holderId]?.placeId !== event.placeId);
|
|
214
|
+
});
|
|
215
|
+
if (remoteVisibleObject) {
|
|
216
|
+
throw new LivingWorldError('effect-invalid', `Visible object ${remoteVisibleObject} is not at the event place`);
|
|
217
|
+
}
|
|
218
|
+
const affectedTensions = new Set(event.effects.flatMap(effect => effect.kind === 'tension.create' || effect.kind === 'tension.update' ? [effect.tensionId] : []));
|
|
219
|
+
if (event.visibleTensionIds && new Set(event.visibleTensionIds).size !== event.visibleTensionIds.length) {
|
|
220
|
+
throw new LivingWorldError('effect-invalid', `Event ${event.seq} repeats a visible tension id`);
|
|
221
|
+
}
|
|
222
|
+
const remoteVisibleTension = event.visibleTensionIds?.find(id => {
|
|
223
|
+
if (affectedTensions.has(id))
|
|
224
|
+
return false;
|
|
225
|
+
const tension = previous.tensions.find(candidate => candidate.id === id);
|
|
226
|
+
return !tension || event.placeId === null || !tension.participantIds.some(personId => previous.people[personId]?.placeId === event.placeId);
|
|
227
|
+
});
|
|
228
|
+
if (remoteVisibleTension) {
|
|
229
|
+
throw new LivingWorldError('effect-invalid', `Visible tension ${remoteVisibleTension} has no participant at the event place`);
|
|
230
|
+
}
|
|
231
|
+
const expectedNearby = nearbyFor(previous, event);
|
|
232
|
+
if (!legacy && JSON.stringify(expectedNearby) !== JSON.stringify([...event.nearbyIds].sort())) {
|
|
233
|
+
throw new LivingWorldError('replay-mismatch', `Event ${event.seq} names nearby people ${event.nearbyIds.join(',')} but ${expectedNearby.join(',')} were nearby`);
|
|
234
|
+
}
|
|
235
|
+
const change = (state) => {
|
|
236
|
+
const playerPresent = event.witnessIds.includes(state.playerId);
|
|
237
|
+
// Older changes leave before this event's changes join, so the trim reads the list as it was.
|
|
238
|
+
if (state.engine >= LOCAL_RECOVERY_ENGINE)
|
|
239
|
+
keepRecentChanges(state, event.seq - RECENT_EVENTS);
|
|
240
|
+
for (const effect of event.effects)
|
|
241
|
+
applyEffect(state, event, effect, replay);
|
|
242
|
+
if (state.engine >= SILENCE_ENGINE)
|
|
243
|
+
noteUnanswered(state, event);
|
|
244
|
+
if (event.thingIds && (new Set(event.thingIds).size !== event.thingIds.length || event.thingIds.some(id => !readEntity(state, id) && !state.objects[id]))) {
|
|
245
|
+
throw new LivingWorldError('effect-invalid', `Event ${event.seq} names a thing that does not exist, or one twice`);
|
|
246
|
+
}
|
|
247
|
+
for (const objectId of event.visibleObjectIds ?? []) {
|
|
248
|
+
const object = state.objects[objectId];
|
|
249
|
+
if (!object)
|
|
250
|
+
throw new LivingWorldError('effect-invalid', `Unknown visible object ${objectId}`);
|
|
251
|
+
const observers = [...(event.perceivedBy ?? event.witnessIds), ...(event.actorId ? [event.actorId] : [])];
|
|
252
|
+
object.custodyKnownTo = [...new Set([...object.custodyKnownTo, ...observers])];
|
|
253
|
+
object.conditionKnownTo = [...new Set([...object.conditionKnownTo, ...observers])];
|
|
254
|
+
}
|
|
255
|
+
if ((event.perceivedBy ?? event.witnessIds).includes(state.playerId)) {
|
|
256
|
+
for (const tensionId of event.visibleTensionIds ?? []) {
|
|
257
|
+
if (affectedTensions.has(tensionId))
|
|
258
|
+
continue;
|
|
259
|
+
const tension = state.tensions.find(candidate => candidate.id === tensionId);
|
|
260
|
+
if (state.engine >= PROBLEM_VIEW_ENGINE) {
|
|
261
|
+
seeTension(state, tension, event, 'shown');
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
tension.playerView = {
|
|
265
|
+
title: tension.playerView?.title ?? tension.title,
|
|
266
|
+
summary: tension.playerView?.summary ?? event.observed ?? tension.summary,
|
|
267
|
+
status: tension.status,
|
|
268
|
+
resolution: tension.status === 'settled' ? event.observed ?? tension.resolution : null,
|
|
269
|
+
latest: tension.playerView ? event.observed ?? null : null,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
applyEventChanges(state, event);
|
|
274
|
+
if (state.engine >= LAZY_METER_ENGINE)
|
|
275
|
+
dropStalePredictions(state, endsAt);
|
|
276
|
+
else {
|
|
277
|
+
advanceScopedEntities(state, event.placeId, endsAt, event.seq);
|
|
278
|
+
for (const destination of new Set(event.effects.flatMap(effect => effect.kind === 'move' ? [effect.placeId] : []))) {
|
|
279
|
+
if (destination !== event.placeId)
|
|
280
|
+
advanceScopedEntities(state, destination, endsAt, event.seq);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (event.effects.some(effect => effect.kind === 'move')) {
|
|
284
|
+
for (const person of Object.values(state.people)) {
|
|
285
|
+
if (person.held?.byId && state.people[person.held.byId].placeId !== person.placeId)
|
|
286
|
+
delete person.held;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
keepWorkingStateBounded(state, replay);
|
|
290
|
+
// A typed cause set up later in the same act still counts as what stopped the person.
|
|
291
|
+
for (const effect of event.effects) {
|
|
292
|
+
if (effect.kind !== 'incapacitate')
|
|
293
|
+
continue;
|
|
294
|
+
const person = state.people[effect.personId];
|
|
295
|
+
const cause = person.incapacitatedBy === undefined ? blockedByGate(state, person.id, 'incapacitate') : null;
|
|
296
|
+
if (cause !== null)
|
|
297
|
+
person.incapacitatedBy = cause;
|
|
298
|
+
}
|
|
299
|
+
// Recovery is decided where the event happens and where the player arrives: a character may arrive
|
|
300
|
+
// several rooms away in a room a command did not load, but the player's next room is always loaded,
|
|
301
|
+
// so a scoped fold and the whole world reach the same state.
|
|
302
|
+
const recoveryPlaces = state.engine >= LOCAL_RECOVERY_ENGINE
|
|
303
|
+
? new Set([event.placeId, ...event.effects.flatMap(effect => effect.kind === 'move' && effect.personId === state.playerId ? [effect.placeId] : [])]) : null;
|
|
304
|
+
for (const person of Object.values(state.people)) {
|
|
305
|
+
if (person.incapacitatedBy === undefined || (recoveryPlaces !== null && !recoveryPlaces.has(person.placeId)))
|
|
306
|
+
continue;
|
|
307
|
+
if (blockedByGate(state, person.id, 'incapacitate') !== null)
|
|
308
|
+
continue;
|
|
309
|
+
person.incapacitated = false;
|
|
310
|
+
delete person.incapacitatedBy;
|
|
311
|
+
}
|
|
312
|
+
rememberEvent(state, event);
|
|
313
|
+
recordChronicle(state, event);
|
|
314
|
+
noteThings(state, event);
|
|
315
|
+
learnThings(state, event);
|
|
316
|
+
// From engine 50 those where something happens learn what lies there in the open; that room is loaded.
|
|
317
|
+
if (state.engine >= SEEN_AT_EVENTS_ENGINE && event.placeId !== null && state.places[event.placeId]) {
|
|
318
|
+
for (const personId of peopleAt(state, event.placeId))
|
|
319
|
+
seeWhatLiesHere(state, personId, event.placeId);
|
|
320
|
+
}
|
|
321
|
+
if (event.actorId !== null && ((event.importance >= 7 && (event.kind === 'attempt' || event.kind === 'speech'))
|
|
322
|
+
|| event.effects.some(effect => effect.kind === 'norm.violation'))) {
|
|
323
|
+
// Each serious act asks every witness for a fresh decision about its actor.
|
|
324
|
+
for (const personId of event.perceivedBy ?? event.witnessIds) {
|
|
325
|
+
if (personId === event.actorId || personId === state.playerId)
|
|
326
|
+
continue;
|
|
327
|
+
const existing = state.stances.find(stance => stance.personId === personId && stance.actorId === event.actorId);
|
|
328
|
+
if (existing)
|
|
329
|
+
Object.assign(existing, { status: 'undecided', sourceSeq: event.seq, changedSeq: event.seq });
|
|
330
|
+
else
|
|
331
|
+
state.stances.push({ personId, actorId: event.actorId, status: 'undecided', sourceSeq: event.seq, changedSeq: event.seq });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (playerPresent && event.placeId !== null)
|
|
335
|
+
observePlace(state, event.placeId, event.at);
|
|
336
|
+
if (event.kind === 'keep-playing')
|
|
337
|
+
state.stoppingPoint = null;
|
|
338
|
+
// Suggestions belong to the latest character line addressed to the player; the player's own words clear them.
|
|
339
|
+
if (event.actorId === state.playerId)
|
|
340
|
+
state.suggestions = [];
|
|
341
|
+
else if (event.suggestions.length > 0)
|
|
342
|
+
state.suggestions = [...event.suggestions];
|
|
343
|
+
if (event.kind === 'finish') {
|
|
344
|
+
state.epilogue = { title: event.summary, text: event.narration ?? event.summary, at: event.at, ...event.ending };
|
|
345
|
+
state.status = 'finished';
|
|
346
|
+
}
|
|
347
|
+
state.clock = endsAt;
|
|
348
|
+
state.revision = event.seq;
|
|
349
|
+
if (state.engine >= PLAYER_REST_ENGINE && event.actorId === state.playerId && event.kind !== 'tick' && event.kind !== 'clock')
|
|
350
|
+
state.playerActedAt = event.at;
|
|
351
|
+
state.events = [...plain(state.events).slice(-RECENT_EVENTS), event];
|
|
352
|
+
};
|
|
353
|
+
if (replay) {
|
|
354
|
+
change(previous);
|
|
355
|
+
return previous;
|
|
356
|
+
}
|
|
357
|
+
const overlay = entityOverlay(previous.entities);
|
|
358
|
+
const next = produce({ ...previous, entities: overlay.table }, change);
|
|
359
|
+
const changed = overlay.changes();
|
|
360
|
+
const result = { ...next, entities: Object.keys(changed).length === 0 ? previous.entities : { ...previous.entities, ...changed } };
|
|
361
|
+
sealLivingIndexes(result);
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
function applyEffect(state, event, effect, replay) {
|
|
365
|
+
// From engine 49 a world's own things, as its opening sets them out, are known to everyone in it.
|
|
366
|
+
const opening = event.kind === 'opening' && state.engine >= THINGS_SEEN_ENGINE;
|
|
367
|
+
const knownTo = opening ? Object.keys(state.people).sort()
|
|
368
|
+
: [...new Set([...(event.perceivedBy ?? event.witnessIds), ...(event.actorId && state.people[event.actorId] ? [event.actorId] : [])])];
|
|
369
|
+
switch (effect.kind) {
|
|
370
|
+
case 'move': {
|
|
371
|
+
const person = requirePerson(state, effect.personId);
|
|
372
|
+
// The player goes where they choose, or where someone beside them takes them; nothing else moves them.
|
|
373
|
+
if (state.engine >= IDENTITY_ENGINE && person.id === state.playerId && event.actorId !== state.playerId
|
|
374
|
+
&& (event.actorId === null || event.placeId !== person.placeId)) {
|
|
375
|
+
throw new LivingWorldError('effect-invalid', `${person.name} moves only by their own choice or with someone beside them`);
|
|
376
|
+
}
|
|
377
|
+
if (event.actorId === person.id) {
|
|
378
|
+
// The repair hears what holds the person and how an act that frees them must say so first.
|
|
379
|
+
const gate = gatesAgainst(state, person.id, 'move')[0];
|
|
380
|
+
if (gate)
|
|
381
|
+
throw new LivingWorldError('effect-invalid', `${gate.name} (${gate.entityId}) holds ${person.name}; to free them, set its ${gate.propertyId} first in the same ruling, or keep them here`);
|
|
382
|
+
}
|
|
383
|
+
requirePlace(state, effect.placeId);
|
|
384
|
+
const from = person.placeId;
|
|
385
|
+
person.placeId = effect.placeId;
|
|
386
|
+
person.arrivedSeq = event.seq;
|
|
387
|
+
carryHeldEntities(state, person.id, from, effect.placeId);
|
|
388
|
+
// A character may walk into a room the command did not load; from engine 50 only the player,
|
|
389
|
+
// whose next room is always loaded, learns what lies there on arrival.
|
|
390
|
+
if (state.engine < SEEN_AT_EVENTS_ENGINE || person.id === state.playerId)
|
|
391
|
+
seeWhatLiesHere(state, person.id, effect.placeId);
|
|
392
|
+
// Walking off sets aside any long act the person had under way.
|
|
393
|
+
if (state.engine >= WALK_OFF_ENGINE && state.schedule.some(item => item.kind === 'finish' && item.targetId === person.id)) {
|
|
394
|
+
state.schedule = state.schedule.filter(item => !(item.kind === 'finish' && item.targetId === person.id));
|
|
395
|
+
}
|
|
396
|
+
leaveConversations(state, person.id, from, event.at);
|
|
397
|
+
if (person.id === state.playerId)
|
|
398
|
+
observePlace(state, effect.placeId, event.at + event.minutes);
|
|
399
|
+
// Seen arriving where the player stands, the player knows where they are.
|
|
400
|
+
else if (state.people[state.playerId].placeId === effect.placeId)
|
|
401
|
+
state.sightings[person.id] = { placeId: effect.placeId, at: event.at };
|
|
402
|
+
// Seen walking out, they were last seen heading into the next room on their way.
|
|
403
|
+
else if ((event.perceivedBy ?? event.witnessIds).includes(state.playerId)) {
|
|
404
|
+
state.sightings[person.id] = { placeId: travelPath(state, from, effect.placeId)?.[1] ?? effect.placeId, at: event.at, heading: true };
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
case 'say': {
|
|
409
|
+
const speaker = requirePerson(state, effect.personId);
|
|
410
|
+
if (event.placeId === null || speaker.placeId !== event.placeId)
|
|
411
|
+
throw new LivingWorldError('effect-invalid', `${speaker.name} cannot speak at a place they are not in`);
|
|
412
|
+
if (state.engine >= ACT_GATE_ENGINE && event.actorId === speaker.id) {
|
|
413
|
+
const gate = blockedByGate(state, speaker.id, 'speak');
|
|
414
|
+
if (gate)
|
|
415
|
+
throw new LivingWorldError('effect-invalid', `Speech is blocked by ${gate}`);
|
|
416
|
+
}
|
|
417
|
+
for (const target of effect.toIds) {
|
|
418
|
+
const listener = requirePerson(state, target);
|
|
419
|
+
if (listener.placeId !== event.placeId || state.engine >= AWAY_APART_ENGINE && listener.away)
|
|
420
|
+
throw new LivingWorldError('effect-invalid', `${listener.name} is not here to be addressed`);
|
|
421
|
+
}
|
|
422
|
+
const conversation = openConversation(state, event.placeId, [speaker.id, ...effect.toIds], event.at, event.seq);
|
|
423
|
+
conversation.lines.push({ seq: event.seq, at: event.at, speakerId: speaker.id, text: effect.text, toIds: [...effect.toIds] });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
case 'message': {
|
|
427
|
+
checkMessage(state, event, effect);
|
|
428
|
+
const seq = (state.messages.at(-1)?.seq ?? 0) + 1;
|
|
429
|
+
state.messages.push({ id: `message-${event.seq}-${seq}`, seq, eventSeq: event.seq, at: event.at, fromId: effect.fromId, toIds: [...effect.toIds], text: effect.text,
|
|
430
|
+
...(effect.sourcePersonId ? { sourcePersonId: effect.sourcePersonId } : {}) });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
case 'dispatch': {
|
|
434
|
+
if (state.messageTravel === null)
|
|
435
|
+
throw new LivingWorldError('effect-invalid', 'Messages here arrive at once');
|
|
436
|
+
checkMessage(state, event, effect);
|
|
437
|
+
if (state.schedule.some(item => item.id === effect.dispatchId))
|
|
438
|
+
throw new LivingWorldError('effect-invalid', `Delivery ${effect.dispatchId} already exists`);
|
|
439
|
+
// Whose account it passes on is checked now, when it is sent; the reader learns it from the sender.
|
|
440
|
+
const { kind: _kind, dispatchId, sourcePersonId: _source, ...delivery } = effect;
|
|
441
|
+
insertScheduled(state, { id: dispatchId, at: event.at + state.messageTravel.minutes, personId: null, kind: 'deliver',
|
|
442
|
+
intent: `A message from ${state.people[effect.fromId].name}`.slice(0, 600), placeId: null, targetId: null, sourceSeq: event.seq, importance: event.importance, delivery });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
case 'request.open': {
|
|
446
|
+
requirePerson(state, effect.senderId);
|
|
447
|
+
requirePerson(state, effect.receiverId);
|
|
448
|
+
if (effect.senderId === effect.receiverId)
|
|
449
|
+
throw new LivingWorldError('effect-invalid', 'A request needs another recipient');
|
|
450
|
+
if (state.requests.some(request => request.id === effect.requestId))
|
|
451
|
+
throw new LivingWorldError('effect-invalid', `Request ${effect.requestId} already exists`);
|
|
452
|
+
state.requests.push({ id: effect.requestId, senderId: effect.senderId, receiverId: effect.receiverId,
|
|
453
|
+
topic: effect.topic, status: 'open', openedSeq: event.seq, ...(state.engine >= REQUEST_TIME_ENGINE ? { openedAt: event.at } : {}),
|
|
454
|
+
knownTo: [...new Set([effect.senderId, effect.receiverId, ...(event.perceivedBy ?? event.witnessIds)])] });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
case 'norm.violation': {
|
|
458
|
+
if (effect.actorId !== event.actorId || event.placeId === null)
|
|
459
|
+
throw new LivingWorldError('effect-invalid', 'Norm violation needs the actor and place');
|
|
460
|
+
const valid = normViolationsFor(state, effect.actorId, event.placeId, effect.actionKind)
|
|
461
|
+
.some(violation => violation.normEntityId === effect.normEntityId);
|
|
462
|
+
if (!valid)
|
|
463
|
+
throw new LivingWorldError('effect-invalid', `Norm ${effect.normEntityId} does not forbid ${effect.actionKind} here`);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
case 'stance': {
|
|
467
|
+
requirePerson(state, effect.personId);
|
|
468
|
+
requirePerson(state, effect.actorId);
|
|
469
|
+
if (effect.personId === effect.actorId || effect.personId === state.playerId)
|
|
470
|
+
throw new LivingWorldError('effect-invalid', 'A stance needs another character');
|
|
471
|
+
const existing = state.stances.find(stance => stance.personId === effect.personId && stance.actorId === effect.actorId);
|
|
472
|
+
// A standing already recorded from this source proves the witnessing, however old the event is.
|
|
473
|
+
if (existing?.sourceSeq !== effect.sourceSeq) {
|
|
474
|
+
const cause = state.events.find(item => item.seq === effect.sourceSeq);
|
|
475
|
+
if (!cause || cause.actorId !== effect.actorId || !(cause.perceivedBy ?? cause.witnessIds).includes(effect.personId)) {
|
|
476
|
+
throw new LivingWorldError('effect-invalid', 'Stance needs a witnessed source event');
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (existing) {
|
|
480
|
+
existing.status = effect.status;
|
|
481
|
+
existing.sourceSeq = effect.sourceSeq;
|
|
482
|
+
existing.changedSeq = event.seq;
|
|
483
|
+
}
|
|
484
|
+
else
|
|
485
|
+
state.stances.push({ personId: effect.personId, actorId: effect.actorId, status: effect.status, sourceSeq: effect.sourceSeq, changedSeq: event.seq });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
case 'request.remind': {
|
|
489
|
+
if (state.engine < REMINDER_ENGINE)
|
|
490
|
+
throw new LivingWorldError('effect-invalid', 'This world keeps no reminders');
|
|
491
|
+
const request = state.requests.find(item => item.id === effect.requestId);
|
|
492
|
+
if (!request)
|
|
493
|
+
throw new LivingWorldError('effect-invalid', `Unknown request ${effect.requestId}`);
|
|
494
|
+
if (request.status !== 'open')
|
|
495
|
+
throw new LivingWorldError('effect-invalid', `Request ${effect.requestId} is already ${request.status}`);
|
|
496
|
+
if (event.actorId !== request.senderId)
|
|
497
|
+
throw new LivingWorldError('effect-invalid', `Only the one who asked in request ${effect.requestId} reminds of it`);
|
|
498
|
+
request.remindedAt = event.at;
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
case 'request.update': {
|
|
502
|
+
const request = state.requests.find(item => item.id === effect.requestId);
|
|
503
|
+
if (!request)
|
|
504
|
+
throw new LivingWorldError('effect-invalid', `Unknown request ${effect.requestId}`);
|
|
505
|
+
if (request.status !== 'open')
|
|
506
|
+
throw new LivingWorldError('effect-invalid', `Request ${effect.requestId} is already ${request.status}`);
|
|
507
|
+
if (event.actorId !== request.receiverId && event.actorId !== request.senderId) {
|
|
508
|
+
throw new LivingWorldError('effect-invalid', `Only the people in request ${effect.requestId} can update it`);
|
|
509
|
+
}
|
|
510
|
+
request.status = effect.status;
|
|
511
|
+
if (effect.answer !== undefined)
|
|
512
|
+
request.answer = { text: effect.answer, seq: event.seq };
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
case 'object.update': {
|
|
516
|
+
const object = state.objects[effect.objectId];
|
|
517
|
+
if (!object)
|
|
518
|
+
throw new LivingWorldError('effect-invalid', `Unknown object ${effect.objectId}`);
|
|
519
|
+
if (effect.placeId !== null)
|
|
520
|
+
requirePlace(state, effect.placeId);
|
|
521
|
+
if (effect.holderId !== null)
|
|
522
|
+
requirePerson(state, effect.holderId);
|
|
523
|
+
if (effect.ownerId !== null)
|
|
524
|
+
requirePerson(state, effect.ownerId);
|
|
525
|
+
if (effect.placeId !== null && effect.holderId !== null)
|
|
526
|
+
throw new LivingWorldError('effect-invalid', `${object.name} cannot be both placed and held`);
|
|
527
|
+
if (effect.placeId !== null || effect.holderId !== null) {
|
|
528
|
+
object.custodyKnownTo = observedObjectIds(event).includes(effect.objectId)
|
|
529
|
+
? [...new Set([...(event.perceivedBy ?? event.witnessIds), ...(event.actorId ? [event.actorId] : [])])]
|
|
530
|
+
: event.actorId === state.playerId ? [state.playerId] : [];
|
|
531
|
+
}
|
|
532
|
+
if (effect.placeId !== null) {
|
|
533
|
+
object.placeId = effect.placeId;
|
|
534
|
+
object.holderId = null;
|
|
535
|
+
}
|
|
536
|
+
if (effect.holderId !== null) {
|
|
537
|
+
object.holderId = effect.holderId;
|
|
538
|
+
object.placeId = null;
|
|
539
|
+
}
|
|
540
|
+
if (effect.ownerId !== null)
|
|
541
|
+
object.ownerId = effect.ownerId;
|
|
542
|
+
if (effect.condition !== null) {
|
|
543
|
+
object.condition = state.engine >= CONDITION_SIDES_ENGINE ? withSide(object, effect.aspect, effect.condition, state.engine >= WHOLE_SIDES_ENGINE, state.engine >= SIDE_ORDER_ENGINE) : effect.condition;
|
|
544
|
+
object.conditionKnownTo = observedObjectIds(event).includes(effect.objectId)
|
|
545
|
+
? [...new Set([...(event.perceivedBy ?? event.witnessIds), ...(event.actorId ? [event.actorId] : [])])]
|
|
546
|
+
: event.actorId === state.playerId ? [state.playerId] : [];
|
|
547
|
+
}
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
case 'object.create': {
|
|
551
|
+
if (state.objects[effect.objectId])
|
|
552
|
+
throw new LivingWorldError('effect-invalid', `Object ${effect.objectId} already exists`);
|
|
553
|
+
if ((effect.placeId === null) === (effect.holderId === null))
|
|
554
|
+
throw new LivingWorldError('effect-invalid', `${effect.name} needs exactly one of a place or a holder`);
|
|
555
|
+
if (effect.placeId !== null)
|
|
556
|
+
requirePlace(state, effect.placeId);
|
|
557
|
+
if (effect.holderId !== null)
|
|
558
|
+
requirePerson(state, effect.holderId);
|
|
559
|
+
if (effect.ownerId !== null)
|
|
560
|
+
requirePerson(state, effect.ownerId);
|
|
561
|
+
state.objects[effect.objectId] = { id: effect.objectId, name: effect.name, summary: effect.summary, placeId: effect.placeId, holderId: effect.holderId, ownerId: effect.ownerId, condition: effect.condition, singlePersonMovable: true, introducedAt: event.seq,
|
|
562
|
+
custodyKnownTo: observedObjectIds(event).includes(effect.objectId) ? [...new Set([...(event.perceivedBy ?? event.witnessIds), ...(event.actorId ? [event.actorId] : [])])] : [],
|
|
563
|
+
conditionKnownTo: observedObjectIds(event).includes(effect.objectId) ? [...new Set([...(event.perceivedBy ?? event.witnessIds), ...(event.actorId ? [event.actorId] : [])])] : [] };
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
case 'place.condition': {
|
|
567
|
+
const place = requirePlace(state, effect.placeId);
|
|
568
|
+
if (effect.aspect) {
|
|
569
|
+
const previous = place.conditionAspects[effect.aspect];
|
|
570
|
+
if (effect.add === null) {
|
|
571
|
+
if (previous?.index !== undefined) {
|
|
572
|
+
place.conditions.splice(previous.index, 1);
|
|
573
|
+
for (const current of Object.values(place.conditionAspects))
|
|
574
|
+
if (current.index !== undefined && current.index > previous.index)
|
|
575
|
+
current.index -= 1;
|
|
576
|
+
}
|
|
577
|
+
delete place.conditionAspects[effect.aspect];
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
const index = previous?.index ?? place.conditions.length;
|
|
581
|
+
if (previous?.index === undefined)
|
|
582
|
+
place.conditions.push(effect.add);
|
|
583
|
+
else
|
|
584
|
+
place.conditions[index] = effect.add;
|
|
585
|
+
place.conditionAspects[effect.aspect] = { text: effect.add, changedSeq: event.seq, index };
|
|
586
|
+
}
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (effect.add !== null)
|
|
590
|
+
place.conditions.push(effect.add);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
case 'place.create': {
|
|
594
|
+
if (state.places[effect.placeId])
|
|
595
|
+
throw new LivingWorldError('effect-invalid', `Place ${effect.placeId} already exists`);
|
|
596
|
+
for (const connection of effect.connections)
|
|
597
|
+
requirePlace(state, connection);
|
|
598
|
+
state.places[effect.placeId] = { id: effect.placeId, name: effect.name, kind: 'room', summary: effect.summary, connections: [...effect.connections], conditions: [], conditionAspects: {}, introducedAt: event.seq };
|
|
599
|
+
for (const connection of effect.connections)
|
|
600
|
+
state.places[connection].connections.push(effect.placeId);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
case 'person.create': {
|
|
604
|
+
if (state.people[effect.personId])
|
|
605
|
+
throw new LivingWorldError('effect-invalid', `Person ${effect.personId} already exists`);
|
|
606
|
+
const namesake = state.engine >= IDENTITY_ENGINE ? Object.values(state.people).find(person => person.departed === undefined && person.name === effect.name) : undefined;
|
|
607
|
+
if (namesake)
|
|
608
|
+
throw new LivingWorldError('effect-invalid', `${effect.name} (${namesake.id}) is already in the story; tell the newcomer apart by a name of their own or by what anyone can see, such as a coat or a trade, not a number`);
|
|
609
|
+
requirePlace(state, effect.placeId);
|
|
610
|
+
state.people[effect.personId] = {
|
|
611
|
+
id: effect.personId, name: effect.name, role: effect.role, roleId: null, summary: effect.summary, persona: effect.persona, isPlayer: false,
|
|
612
|
+
...(effect.pronouns ? { pronouns: effect.pronouns } : {}),
|
|
613
|
+
placeId: effect.placeId, money: null, mood: 'Arrived', goals: effect.wants === undefined ? [] : [{ id: generatedId(state.engine, 'goal', [effect.personId, 1], `${effect.personId}-goal-1`), text: effect.wants, status: 'active' }],
|
|
614
|
+
relationships: {},
|
|
615
|
+
beliefs: [], memories: [], commitments: [], plan: null, incapacitated: false,
|
|
616
|
+
...(state.engine >= NEWCOMER_ENGINE ? { newcomer: true, arrivedAt: event.at } : {}),
|
|
617
|
+
};
|
|
618
|
+
if ((event.perceivedBy ?? event.witnessIds).includes(state.playerId) && effect.placeId === event.placeId)
|
|
619
|
+
state.sightings[effect.personId] = { placeId: effect.placeId, at: event.at };
|
|
620
|
+
if (state.engine < SEEN_AT_EVENTS_ENGINE)
|
|
621
|
+
seeWhatLiesHere(state, effect.personId, effect.placeId);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
case 'money': {
|
|
625
|
+
const person = requirePerson(state, effect.personId);
|
|
626
|
+
if (person.money === null)
|
|
627
|
+
throw new LivingWorldError('effect-invalid', `${person.name} keeps no money in this world`);
|
|
628
|
+
const next = person.money + effect.delta;
|
|
629
|
+
if (next < 0)
|
|
630
|
+
throw new LivingWorldError('effect-invalid', `${person.name} cannot spend more than they have`);
|
|
631
|
+
person.money = next;
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
case 'transfer': {
|
|
635
|
+
if (effect.fromId === null && effect.toId === null)
|
|
636
|
+
throw new LivingWorldError('effect-invalid', 'A transfer needs someone who pays or someone who receives');
|
|
637
|
+
const unit = state.currency?.name ?? 'units';
|
|
638
|
+
const cents = (amount) => Math.round(amount * 100) / 100;
|
|
639
|
+
const from = effect.fromId === null ? null : purse(state, effect.fromId);
|
|
640
|
+
const to = effect.toId === null ? null : purse(state, effect.toId);
|
|
641
|
+
if (from !== null) {
|
|
642
|
+
const actor = event.actorId === null ? undefined : state.people[event.actorId];
|
|
643
|
+
// An account held by no one moves only by the world's own happenings; one held by someone, by them.
|
|
644
|
+
if (from.account && actor && from.account.holderId === null)
|
|
645
|
+
throw new LivingWorldError('effect-invalid', `${from.name} is moved only by the world's own rules, such as a vote or a due happening`);
|
|
646
|
+
if (from.account && actor && from.account.holderId !== null && from.account.holderId !== actor.id)
|
|
647
|
+
throw new LivingWorldError('effect-invalid', `Only ${state.people[from.account.holderId]?.name ?? from.account.holderId} moves money from ${from.name}`);
|
|
648
|
+
// Someone else's purse is reached only by someone beside them.
|
|
649
|
+
if (from.person && actor && actor.id !== from.person.id && actor.placeId !== from.person.placeId)
|
|
650
|
+
throw new LivingWorldError('effect-invalid', `${actor.name} cannot reach ${from.name}'s money from here`);
|
|
651
|
+
if (!from.allowsDebt && from.balance() < effect.amount)
|
|
652
|
+
throw new LivingWorldError('effect-invalid', `${from.name} holds ${from.balance()} ${unit}; ${effect.amount} is more than that`);
|
|
653
|
+
from.set(cents(from.balance() - effect.amount));
|
|
654
|
+
}
|
|
655
|
+
if (to !== null)
|
|
656
|
+
to.set(cents(to.balance() + effect.amount));
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
case 'relationship': {
|
|
660
|
+
const person = requirePerson(state, effect.personId);
|
|
661
|
+
requirePerson(state, effect.targetId);
|
|
662
|
+
if (effect.personId === effect.targetId)
|
|
663
|
+
throw new LivingWorldError('effect-invalid', 'A relationship needs two people');
|
|
664
|
+
const current = person.relationships[effect.targetId] ?? { trust: 0, warmth: 0, note: '' };
|
|
665
|
+
person.relationships[effect.targetId] = {
|
|
666
|
+
trust: clamp(current.trust + effect.trust), warmth: clamp(current.warmth + effect.warmth),
|
|
667
|
+
note: effect.note === '' ? current.note : effect.note, changedSeq: event.seq,
|
|
668
|
+
};
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
case 'belief': {
|
|
672
|
+
const person = requirePerson(state, effect.personId);
|
|
673
|
+
const evidence = effect.evidence;
|
|
674
|
+
const sourceEvent = evidence?.eventSeq === event.seq ? event : state.events.find(candidate => candidate.seq === evidence?.eventSeq);
|
|
675
|
+
// Messages outlive the recent event window, so a message stays citable after its event scrolls out.
|
|
676
|
+
const message = !sourceEvent && evidence?.kind === 'told' ? state.messages.find(candidate => candidate.eventSeq === evidence.eventSeq
|
|
677
|
+
&& candidate.fromId === evidence.sourceId && candidate.toIds.includes(person.id)) : undefined;
|
|
678
|
+
if (evidence && !sourceEvent && !message)
|
|
679
|
+
throw new LivingWorldError('effect-invalid', 'A belief must cite a recorded event');
|
|
680
|
+
if (evidence?.kind === 'observed' && !((sourceEvent.perceivedBy ?? sourceEvent.witnessIds).includes(person.id))) {
|
|
681
|
+
throw new LivingWorldError('effect-invalid', `${person.name} did not observe the cited event`);
|
|
682
|
+
}
|
|
683
|
+
if ((evidence?.kind === 'told' || evidence?.kind === 'heard') && !message) {
|
|
684
|
+
const speaker = evidence.sourceId;
|
|
685
|
+
if (!speaker || !sourceEvent.effects.some(item => item.kind === 'message' && item.fromId === speaker && item.toIds.includes(person.id)
|
|
686
|
+
|| item.kind === 'say' && item.personId === speaker && sourceEvent.witnessIds.includes(person.id)
|
|
687
|
+
&& (item.toIds.length === 0 || item.toIds.includes(person.id)))) {
|
|
688
|
+
throw new LivingWorldError('effect-invalid', `${person.name} was not told by the cited speaker`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const index = person.beliefs.length + 1;
|
|
692
|
+
person.beliefs.push({
|
|
693
|
+
id: generatedId(state.engine, 'belief', [person.id, event.seq, index], `${person.id}-belief-${event.seq}-${index}`), subject: effect.subject, claim: effect.claim,
|
|
694
|
+
source: { kind: evidence?.kind === 'observed' ? 'witnessed' : evidence?.kind === 'told' || evidence?.kind === 'heard' ? 'told' : 'inferred',
|
|
695
|
+
personId: evidence?.kind === 'told' || evidence?.kind === 'heard' ? evidence.sourceId : null, eventSeq: evidence?.eventSeq ?? event.seq },
|
|
696
|
+
at: event.at,
|
|
697
|
+
});
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
case 'commitment': {
|
|
701
|
+
const person = requirePerson(state, effect.personId);
|
|
702
|
+
requirePerson(state, effect.toId);
|
|
703
|
+
if (replay && !replay.has(person.id))
|
|
704
|
+
replay.set(person.id, new Map(person.commitments.map(item => [item.id, item])));
|
|
705
|
+
const index = replay?.get(person.id);
|
|
706
|
+
if (index ? index.has(effect.commitmentId) : person.commitments.some(commitment => commitment.id === effect.commitmentId))
|
|
707
|
+
throw new LivingWorldError('effect-invalid', `Commitment ${effect.commitmentId} already exists`);
|
|
708
|
+
if (state.engine >= BOUNDED_PERSON_RECORDS_ENGINE && person.commitments.filter(item => item.status === 'open').length >= MAX_OPEN_COMMITMENTS) {
|
|
709
|
+
throw new LivingWorldError('effect-invalid', `${person.name} already has ${MAX_OPEN_COMMITMENTS} open promises; settle one before adding another`);
|
|
710
|
+
}
|
|
711
|
+
// Those who perceived the event heard the promise; a message in the same event carries it to its recipients.
|
|
712
|
+
const heard = [...(event.perceivedBy ?? event.witnessIds), effect.personId,
|
|
713
|
+
...event.effects.flatMap(item => item.kind === 'message' ? item.toIds : [])];
|
|
714
|
+
const dueAt = effect.dueInMinutes === null ? null : event.at + effect.dueInMinutes;
|
|
715
|
+
if (effect.placeId !== undefined)
|
|
716
|
+
requirePlace(state, effect.placeId);
|
|
717
|
+
person.commitments.push({ id: effect.commitmentId, toId: effect.toId, text: effect.text, dueAt, ...(effect.placeId === undefined ? {} : { placeId: effect.placeId }), status: 'open', madeAt: event.at,
|
|
718
|
+
madeSeq: event.seq, knownTo: [...new Set(heard)].sort() });
|
|
719
|
+
index?.set(effect.commitmentId, person.commitments[person.commitments.length - 1]);
|
|
720
|
+
// A promise to be somewhere at a time becomes a decision the promiser must make in time to get there,
|
|
721
|
+
// one no routine plan puts aside.
|
|
722
|
+
if (state.engine >= DEADLINE_ENGINE && dueAt !== null && effect.placeId !== undefined && !person.isPlayer) {
|
|
723
|
+
insertScheduled(state, { id: generatedId(state.engine, 'keep', [effect.commitmentId]), at: Math.max(event.at + 1, dueAt - PROMISE_LEAD_MINUTES), personId: person.id, kind: 'plan',
|
|
724
|
+
intent: `Keep your promise to ${state.people[effect.toId].name}: ${effect.text}`.slice(0, MAX_LINE), placeId: effect.placeId, targetId: effect.toId, sourceSeq: event.seq, importance: PROMISE_IMPORTANCE });
|
|
725
|
+
}
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
case 'commitment.update': {
|
|
729
|
+
const person = requirePerson(state, effect.personId);
|
|
730
|
+
const commitment = replay ? replay.get(person.id)?.get(effect.commitmentId) : person.commitments.find(candidate => candidate.id === effect.commitmentId);
|
|
731
|
+
if (!commitment)
|
|
732
|
+
throw new LivingWorldError('effect-invalid', `Unknown commitment ${effect.commitmentId}`);
|
|
733
|
+
if (commitment.status !== 'open')
|
|
734
|
+
throw new LivingWorldError('effect-invalid', `Commitment ${effect.commitmentId} is already ${commitment.status}`);
|
|
735
|
+
commitment.status = effect.status;
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
case 'goal': {
|
|
739
|
+
const person = requirePerson(state, effect.personId);
|
|
740
|
+
if (effect.dropGoalId !== null) {
|
|
741
|
+
const goal = person.goals.find(candidate => candidate.id === effect.dropGoalId);
|
|
742
|
+
if (!goal)
|
|
743
|
+
throw new LivingWorldError('effect-invalid', `Unknown goal ${effect.dropGoalId}`);
|
|
744
|
+
goal.status = 'dropped';
|
|
745
|
+
}
|
|
746
|
+
if (effect.doneGoalId !== undefined && effect.doneGoalId !== null) {
|
|
747
|
+
const goal = person.goals.find(candidate => candidate.id === effect.doneGoalId);
|
|
748
|
+
if (!goal)
|
|
749
|
+
throw new LivingWorldError('effect-invalid', `Unknown goal ${effect.doneGoalId}`);
|
|
750
|
+
goal.status = 'done';
|
|
751
|
+
}
|
|
752
|
+
if (effect.add !== null)
|
|
753
|
+
person.goals.push({ id: generatedId(state.engine, 'goal', [person.id, event.seq, person.goals.length + 1], `${person.id}-goal-${event.seq}-${person.goals.length + 1}`), text: effect.add, status: 'active' });
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
case 'mood': {
|
|
757
|
+
requirePerson(state, effect.personId).mood = effect.mood;
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
case 'hold': {
|
|
761
|
+
const person = requirePerson(state, effect.personId);
|
|
762
|
+
if (effect.byId !== null)
|
|
763
|
+
requirePerson(state, effect.byId);
|
|
764
|
+
if (effect.reason === null)
|
|
765
|
+
delete person.held;
|
|
766
|
+
else
|
|
767
|
+
person.held = { byId: effect.byId, reason: effect.reason };
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
case 'role': {
|
|
771
|
+
const person = requirePerson(state, effect.personId);
|
|
772
|
+
person.role = effect.role;
|
|
773
|
+
person.roleId = effect.roleId;
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
case 'person.condition': {
|
|
777
|
+
const person = requirePerson(state, effect.personId);
|
|
778
|
+
if (effect.condition === null)
|
|
779
|
+
delete person.condition;
|
|
780
|
+
else
|
|
781
|
+
person.condition = effect.condition;
|
|
782
|
+
person.conditionChangedSeq = event.seq;
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
case 'schedule': {
|
|
786
|
+
if (effect.personId !== null)
|
|
787
|
+
requirePerson(state, effect.personId);
|
|
788
|
+
if (effect.placeId !== null)
|
|
789
|
+
requirePlace(state, effect.placeId);
|
|
790
|
+
// A world happening may concern anything the world holds, such as the expiry a ruling just made;
|
|
791
|
+
// every other item concerns a person.
|
|
792
|
+
if (effect.targetId !== null && effect.scheduleKind === 'world')
|
|
793
|
+
requireKnown(state, effect.targetId);
|
|
794
|
+
else if (effect.targetId !== null)
|
|
795
|
+
requirePerson(state, effect.targetId);
|
|
796
|
+
if (state.schedule.some(item => item.id === effect.scheduledId))
|
|
797
|
+
throw new LivingWorldError('effect-invalid', `Scheduled item ${effect.scheduledId} already exists`);
|
|
798
|
+
if (effect.scheduleKind === 'deliver')
|
|
799
|
+
throw new LivingWorldError('effect-invalid', 'A delivery comes only from a dispatched message');
|
|
800
|
+
if (effect.scheduleKind === 'back')
|
|
801
|
+
throw new LivingWorldError('effect-invalid', 'A return comes only from someone going away');
|
|
802
|
+
if (effect.scheduleKind === 'count')
|
|
803
|
+
throw new LivingWorldError('effect-invalid', 'A vote opens and is counted only at its own written times');
|
|
804
|
+
if (effect.scheduleKind === 'finish' && (effect.targetId === null || effect.placeId === null || effect.held === undefined))
|
|
805
|
+
throw new LivingWorldError('effect-invalid', 'A long act ends for its actor where it began, as it was ruled');
|
|
806
|
+
// Someone away answers a message once they are back; from engine 49, only one asleep at night waits.
|
|
807
|
+
const away = effect.personId !== null && effect.scheduleKind === 'reply' ? state.people[effect.personId].away : undefined;
|
|
808
|
+
const waits = away !== undefined && (state.engine < AWAY_REPLY_ENGINE || isNight(event.at + effect.inMinutes));
|
|
809
|
+
insertScheduled(state, { id: effect.scheduledId, at: Math.max(event.at + effect.inMinutes, waits ? away.until : 0), personId: effect.personId, kind: effect.scheduleKind, intent: effect.intent, placeId: effect.placeId, targetId: effect.targetId, sourceSeq: event.seq, importance: effect.importance ?? event.importance,
|
|
810
|
+
...(effect.everyMinutes === undefined ? {} : { everyMinutes: effect.everyMinutes }), ...(effect.series === undefined ? {} : { series: effect.series }),
|
|
811
|
+
...(effect.beat === undefined ? {} : { beat: effect.beat }),
|
|
812
|
+
...(state.engine >= BOUNDED_IDENTIFIERS_ENGINE && effect.replanned ? { replanned: true } : {}),
|
|
813
|
+
...(effect.held === undefined ? {} : { held: { at: event.at, ...effect.held } }) });
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
case 'deadline.passed': {
|
|
817
|
+
state.passedDeadlines = [...(state.passedDeadlines ?? []), { what: effect.what, at: event.at, outcome: effect.outcome,
|
|
818
|
+
...(effect.averted && state.engine >= AVERTED_ENGINE ? { averted: true } : {}) }].slice(-8);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
case 'unschedule': {
|
|
822
|
+
const index = state.schedule.findIndex(item => item.id === effect.scheduledId);
|
|
823
|
+
const predicted = index === -1 ? plain(state.meterSchedule).findIndex(item => item.id === effect.scheduledId) : -1;
|
|
824
|
+
if (index === -1 && predicted === -1)
|
|
825
|
+
throw new LivingWorldError('effect-invalid', `Unknown scheduled item ${effect.scheduledId}`);
|
|
826
|
+
if (index !== -1)
|
|
827
|
+
state.schedule.splice(index, 1);
|
|
828
|
+
else
|
|
829
|
+
state.meterSchedule = plain(state.meterSchedule).filter((_, index) => index !== predicted);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
case 'plan': {
|
|
833
|
+
const person = requirePerson(state, effect.personId);
|
|
834
|
+
if (effect.plan?.placeId)
|
|
835
|
+
requirePlace(state, effect.plan.placeId);
|
|
836
|
+
if (effect.plan?.targetId)
|
|
837
|
+
requirePerson(state, effect.plan.targetId);
|
|
838
|
+
person.plan = effect.plan;
|
|
839
|
+
// What someone has been doing lately stays in view when they decide again.
|
|
840
|
+
if (effect.plan && state.engine >= CHRONICLE_ENGINE)
|
|
841
|
+
person.planHistory = [...(person.planHistory ?? []), { at: event.at, intent: effect.plan.intent }].slice(-6);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
case 'fact': {
|
|
845
|
+
if (effect.aspect) {
|
|
846
|
+
const previous = state.factAspects[effect.aspect];
|
|
847
|
+
if (effect.add === null) {
|
|
848
|
+
if (previous?.index !== undefined) {
|
|
849
|
+
state.facts.splice(previous.index, 1);
|
|
850
|
+
for (const current of Object.values(state.factAspects))
|
|
851
|
+
if (current.index !== undefined && current.index > previous.index)
|
|
852
|
+
current.index -= 1;
|
|
853
|
+
}
|
|
854
|
+
delete state.factAspects[effect.aspect];
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
const index = previous?.index ?? state.facts.length;
|
|
858
|
+
if (previous?.index === undefined)
|
|
859
|
+
state.facts.push(effect.add);
|
|
860
|
+
else
|
|
861
|
+
state.facts[index] = effect.add;
|
|
862
|
+
state.factAspects[effect.aspect] = { text: effect.add, changedSeq: event.seq, index };
|
|
863
|
+
}
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (effect.add !== null)
|
|
867
|
+
state.facts.push(effect.add);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
case 'tension.create': {
|
|
871
|
+
if (state.tensions.some(tension => tension.id === effect.tensionId))
|
|
872
|
+
throw new LivingWorldError('effect-invalid', `Tension ${effect.tensionId} already exists`);
|
|
873
|
+
for (const participant of effect.participantIds)
|
|
874
|
+
requirePerson(state, participant);
|
|
875
|
+
const playerCanSee = (event.perceivedBy ?? event.witnessIds).includes(state.playerId)
|
|
876
|
+
&& (event.visibleTensionIds ?? (event.observed === undefined ? [effect.tensionId] : [])).includes(effect.tensionId);
|
|
877
|
+
const modern = state.engine >= PROBLEM_VIEW_ENGINE;
|
|
878
|
+
state.tensions.push({ id: effect.tensionId, title: effect.title, summary: effect.summary, participantIds: [...effect.participantIds], status: 'open', resolution: null, changedAt: event.at,
|
|
879
|
+
...(effect.openedBy === undefined ? {} : { openedBy: effect.openedBy }),
|
|
880
|
+
playerView: playerCanSee && !modern ? { title: effect.title.slice(0, 200), summary: event.observed ?? effect.summary, status: 'open', resolution: null, latest: null } : null });
|
|
881
|
+
if (playerCanSee && modern)
|
|
882
|
+
seeTension(state, state.tensions.at(-1), event, 'created');
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
case 'tension.update': {
|
|
886
|
+
const tension = state.tensions.find(candidate => candidate.id === effect.tensionId);
|
|
887
|
+
if (!tension)
|
|
888
|
+
throw new LivingWorldError('effect-invalid', `Unknown tension ${effect.tensionId}`);
|
|
889
|
+
const settling = tension.status === 'open' && effect.status === 'settled';
|
|
890
|
+
tension.status = effect.status;
|
|
891
|
+
tension.changedAt = event.at;
|
|
892
|
+
if (effect.note !== null) {
|
|
893
|
+
if (effect.status === 'settled')
|
|
894
|
+
tension.resolution = effect.note;
|
|
895
|
+
else
|
|
896
|
+
tension.summary = effect.note;
|
|
897
|
+
}
|
|
898
|
+
if (settling)
|
|
899
|
+
state.stoppingPoint = { at: event.at, tensionId: tension.id, title: tension.title, summary: tension.resolution ?? tension.summary };
|
|
900
|
+
if ((event.perceivedBy ?? event.witnessIds).includes(state.playerId)
|
|
901
|
+
&& (event.visibleTensionIds ?? (event.observed === undefined ? [effect.tensionId] : [])).includes(effect.tensionId)) {
|
|
902
|
+
if (state.engine >= PROBLEM_VIEW_ENGINE) {
|
|
903
|
+
seeTension(state, tension, event, 'changed');
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
tension.playerView = { title: tension.playerView?.title ?? tension.title.slice(0, 200),
|
|
907
|
+
summary: tension.playerView?.summary ?? event.observed ?? tension.summary, status: tension.status,
|
|
908
|
+
resolution: tension.status === 'settled' ? event.observed ?? tension.resolution : null,
|
|
909
|
+
latest: tension.playerView ? event.observed ?? effect.note : null };
|
|
910
|
+
}
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
case 'memory': {
|
|
914
|
+
const person = requirePerson(state, effect.personId);
|
|
915
|
+
for (const participant of effect.participantIds)
|
|
916
|
+
requirePerson(state, participant);
|
|
917
|
+
addMemory(state, person.id, event, effect.text, effect.importance, effect.participantIds);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
case 'incapacitate': {
|
|
921
|
+
const person = requirePerson(state, effect.personId);
|
|
922
|
+
person.incapacitated = true;
|
|
923
|
+
// A typed cause is remembered, so the person recovers when that cause no longer stops them.
|
|
924
|
+
const cause = blockedByGate(state, person.id, 'incapacitate');
|
|
925
|
+
if (cause !== null)
|
|
926
|
+
person.incapacitatedBy = cause;
|
|
927
|
+
if (person.id === state.playerId)
|
|
928
|
+
state.forcedEnding = effect.reason;
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
case 'person.away': {
|
|
932
|
+
const person = requirePerson(state, effect.personId);
|
|
933
|
+
if (person.isPlayer)
|
|
934
|
+
throw new LivingWorldError('effect-invalid', 'The player decides where they go');
|
|
935
|
+
if (person.departed || person.away)
|
|
936
|
+
throw new LivingWorldError('effect-invalid', `${person.name} is not here to leave`);
|
|
937
|
+
if (person.incapacitated)
|
|
938
|
+
throw new LivingWorldError('effect-invalid', `${person.name} cannot leave while unable to act`);
|
|
939
|
+
if (heldHere(state, person.id))
|
|
940
|
+
throw new LivingWorldError('effect-invalid', `${person.name} is kept here and cannot leave`);
|
|
941
|
+
const gate = gatesAgainst(state, person.id, 'move')[0];
|
|
942
|
+
if (gate)
|
|
943
|
+
throw new LivingWorldError('effect-invalid', `${gate.name} (${gate.entityId}) holds ${person.name}; to free them, set its ${gate.propertyId} first in the same ruling, or keep them here`);
|
|
944
|
+
const until = event.at + effect.minutes;
|
|
945
|
+
person.away = { at: event.at, until, reason: effect.reason };
|
|
946
|
+
person.plan = { intent: effect.reason, placeId: null, targetId: null, dueAt: until };
|
|
947
|
+
leaveConversations(state, person.id, person.placeId, event.at);
|
|
948
|
+
// What they meant to do here waits; messages to them are answered when they are back.
|
|
949
|
+
// From engine 42 a long act under way is set aside too.
|
|
950
|
+
state.schedule = state.schedule.filter(item => (item.personId !== person.id || item.kind === 'reply')
|
|
951
|
+
&& !(state.engine >= AWAY_APART_ENGINE && item.kind === 'finish' && item.targetId === person.id))
|
|
952
|
+
.map(item => item.personId === person.id && item.at < until
|
|
953
|
+
&& !(state.engine >= AWAY_REPLY_ENGINE && item.kind === 'reply' && !isNight(item.at)) ? { ...item, at: until } : item)
|
|
954
|
+
.sort((left, right) => left.at - right.at || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
|
|
955
|
+
insertScheduled(state, { id: generatedId(state.engine, 'back', [event.seq, person.id], `back-${event.seq}-${person.id}`.slice(0, 64)), at: until, personId: person.id, kind: 'back', intent: effect.reason,
|
|
956
|
+
placeId: person.placeId, targetId: null, sourceSeq: event.seq, importance: 3 });
|
|
957
|
+
// Seen going, they are known to have left for a while.
|
|
958
|
+
if (event.witnessIds.includes(state.playerId) || event.perceivedBy?.includes(state.playerId))
|
|
959
|
+
state.sightings[person.id] = { placeId: person.placeId, at: event.at, away: true };
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
case 'person.back': {
|
|
963
|
+
const person = requirePerson(state, effect.personId);
|
|
964
|
+
if (!person.away)
|
|
965
|
+
throw new LivingWorldError('effect-invalid', `${person.name} is not away`);
|
|
966
|
+
delete person.away;
|
|
967
|
+
person.arrivedSeq = event.seq;
|
|
968
|
+
if (state.engine < SEEN_AT_EVENTS_ENGINE)
|
|
969
|
+
seeWhatLiesHere(state, person.id, person.placeId);
|
|
970
|
+
if (state.engine >= REST_ENGINE)
|
|
971
|
+
person.restedAt = event.at;
|
|
972
|
+
if (state.people[state.playerId].placeId === person.placeId)
|
|
973
|
+
state.sightings[person.id] = { placeId: person.placeId, at: event.at };
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
case 'person.leave': {
|
|
977
|
+
const person = requirePerson(state, effect.personId);
|
|
978
|
+
if (person.isPlayer)
|
|
979
|
+
throw new LivingWorldError('effect-invalid', 'The player decides when their own story ends');
|
|
980
|
+
if (person.departed)
|
|
981
|
+
throw new LivingWorldError('effect-invalid', `${person.name} has already left`);
|
|
982
|
+
person.departed = { at: event.at, reason: effect.reason };
|
|
983
|
+
person.plan = null;
|
|
984
|
+
leaveConversations(state, person.id, person.placeId, event.at);
|
|
985
|
+
// What they meant to do here goes with them.
|
|
986
|
+
state.schedule = state.schedule.filter(item => item.personId !== person.id);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
case 'conversation.end': {
|
|
990
|
+
const conversation = state.conversations.find(candidate => candidate.id === effect.conversationId);
|
|
991
|
+
if (!conversation)
|
|
992
|
+
throw new LivingWorldError('effect-invalid', `Unknown conversation ${effect.conversationId}`);
|
|
993
|
+
if (conversation.endedAt === null)
|
|
994
|
+
conversation.endedAt = event.at;
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
case 'kind.define': {
|
|
998
|
+
const definition = effect.definition;
|
|
999
|
+
if (state.kinds[definition.id])
|
|
1000
|
+
throw new LivingWorldError('effect-invalid', `Kind ${definition.id} already exists`);
|
|
1001
|
+
if (event.kind !== 'opening' && definition.mechanics.some(mechanic => mechanic.kind === 'signal' && mechanic.channel === GLOBAL_SIGNAL_CHANNEL)) {
|
|
1002
|
+
throw new LivingWorldError('effect-invalid', `Only the world's own data may define what reaches everyone, as ${definition.id} would`);
|
|
1003
|
+
}
|
|
1004
|
+
const propertyIds = new Set(definition.properties.map(property => property.id));
|
|
1005
|
+
const relationIds = new Set(definition.relations.map(relation => relation.id));
|
|
1006
|
+
if (propertyIds.size !== definition.properties.length || relationIds.size !== definition.relations.length) {
|
|
1007
|
+
throw new LivingWorldError('effect-invalid', `Kind ${definition.id} repeats a slot id`);
|
|
1008
|
+
}
|
|
1009
|
+
for (const relation of definition.relations) {
|
|
1010
|
+
if (relation.targetKindId !== definition.id && !state.kinds[relation.targetKindId]) {
|
|
1011
|
+
throw new LivingWorldError('effect-invalid', `Kind ${definition.id} refers to unknown target kind ${relation.targetKindId}`);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
for (const mechanic of definition.mechanics) {
|
|
1015
|
+
if ('propertyId' in mechanic && mechanic.propertyId !== null && !propertyIds.has(mechanic.propertyId)) {
|
|
1016
|
+
throw new LivingWorldError('effect-invalid', `Kind ${definition.id} has unknown property ${mechanic.propertyId}`);
|
|
1017
|
+
}
|
|
1018
|
+
if ('relationId' in mechanic && mechanic.relationId !== null && !relationIds.has(mechanic.relationId)) {
|
|
1019
|
+
throw new LivingWorldError('effect-invalid', `Kind ${definition.id} has unknown relation ${mechanic.relationId}`);
|
|
1020
|
+
}
|
|
1021
|
+
if (mechanic.kind === 'spread' && definition.properties.find(property => property.id === mechanic.propertyId)?.type !== 'level') {
|
|
1022
|
+
throw new LivingWorldError('effect-invalid', `Kind ${definition.id} spreads a property that is not a level`);
|
|
1023
|
+
}
|
|
1024
|
+
if (mechanic.kind === 'authority' && (mechanic.propertyId === null) === (mechanic.relationId === null)) {
|
|
1025
|
+
throw new LivingWorldError('effect-invalid', 'Authority needs exactly one property or relation');
|
|
1026
|
+
}
|
|
1027
|
+
const targetProperty = 'propertyId' in mechanic ? definition.properties.find(property => property.id === mechanic.propertyId) : undefined;
|
|
1028
|
+
if ((mechanic.kind === 'change' && (mechanic.onEvent
|
|
1029
|
+
? mechanic.deltaPerMinute !== 0 || !targetProperty || !valueMatchesType(targetProperty.type, mechanic.onEvent.value)
|
|
1030
|
+
: targetProperty?.type !== 'number'))
|
|
1031
|
+
|| mechanic.kind === 'trigger' && targetProperty?.type !== 'number') {
|
|
1032
|
+
throw new LivingWorldError('effect-invalid', `Kind ${definition.id} changes a property that is not a number`);
|
|
1033
|
+
}
|
|
1034
|
+
if (mechanic.kind === 'change' && mechanic.onEvent
|
|
1035
|
+
&& definition.properties.find(property => property.id === mechanic.onEvent.subjectPropertyId)?.type !== 'id') {
|
|
1036
|
+
throw new LivingWorldError('effect-invalid', `Kind ${definition.id} needs an id subject property for event change`);
|
|
1037
|
+
}
|
|
1038
|
+
if (mechanic.kind === 'gate' && definition.properties.find(property => property.id === mechanic.subjectPropertyId)?.type !== 'id') {
|
|
1039
|
+
throw new LivingWorldError('effect-invalid', `Kind ${definition.id} needs an id subject property for its gate`);
|
|
1040
|
+
}
|
|
1041
|
+
if (mechanic.kind === 'gate' && mechanic.atLeast !== undefined && targetProperty?.type !== 'level') {
|
|
1042
|
+
throw new LivingWorldError('effect-invalid', `Kind ${definition.id} needs a level property for a level gate`);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
state.kinds[definition.id] = definition;
|
|
1046
|
+
state.kindRequests = state.kindRequests.filter(request => request.id !== definition.id);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
case 'kind.request': {
|
|
1050
|
+
if (state.kinds[effect.request.id])
|
|
1051
|
+
return;
|
|
1052
|
+
// A request is known by its id; asking again in other words is the same pending request.
|
|
1053
|
+
if (!state.kindRequests.some(request => request.id === effect.request.id))
|
|
1054
|
+
state.kindRequests.push(effect.request);
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
case 'entity.create': {
|
|
1058
|
+
if (!state.kinds[effect.kindId])
|
|
1059
|
+
throw new LivingWorldError('effect-invalid', `Unknown kind ${effect.kindId}`);
|
|
1060
|
+
if (effect.placeId !== null)
|
|
1061
|
+
requirePlace(state, effect.placeId);
|
|
1062
|
+
if (state.entities[effect.entityId] || state.people[effect.entityId] || state.places[effect.entityId] || state.objects[effect.entityId]) {
|
|
1063
|
+
throw new LivingWorldError('effect-invalid', `Entity ${effect.entityId} already exists`);
|
|
1064
|
+
}
|
|
1065
|
+
if ((effect.condition !== undefined || effect.holderId !== undefined) && effect.placeId === null) {
|
|
1066
|
+
throw new LivingWorldError('effect-invalid', `${effect.name} belongs to no place, so it has no condition and no one holds it`);
|
|
1067
|
+
}
|
|
1068
|
+
if (effect.holderId !== undefined && state.engine >= UNHELD_ENGINE && neverHeld(state.engine, effect.kindId, state.kinds[effect.kindId])) {
|
|
1069
|
+
throw new LivingWorldError('effect-invalid', `${effect.name} stays where it is; no one holds it`);
|
|
1070
|
+
}
|
|
1071
|
+
if (effect.holderId !== undefined) {
|
|
1072
|
+
const holder = requirePerson(state, effect.holderId);
|
|
1073
|
+
if (holder.departed || holder.away || holder.placeId !== effect.placeId)
|
|
1074
|
+
throw new LivingWorldError('effect-invalid', `${holder.name} is not in ${state.places[effect.placeId]?.name ?? effect.placeId} to hold ${effect.name}`);
|
|
1075
|
+
}
|
|
1076
|
+
const deciding = effect.kindId === 'decision' && state.engine >= VOTES_ENGINE;
|
|
1077
|
+
if ((effect.memberIds !== undefined || effect.settles !== undefined || effect.transfers !== undefined) && !deciding)
|
|
1078
|
+
throw new LivingWorldError('effect-invalid', `Only a decision has members and settles a problem`);
|
|
1079
|
+
if (deciding) {
|
|
1080
|
+
if (effect.placeId === null)
|
|
1081
|
+
throw new LivingWorldError('effect-invalid', `${effect.name} needs the place where it is voted on`);
|
|
1082
|
+
if (!effect.memberIds?.length)
|
|
1083
|
+
throw new LivingWorldError('effect-invalid', `${effect.name} needs the members who vote on it`);
|
|
1084
|
+
for (const memberId of effect.memberIds)
|
|
1085
|
+
requirePerson(state, memberId);
|
|
1086
|
+
if (effect.settles !== undefined && !state.tensions.some(tension => tension.id === effect.settles))
|
|
1087
|
+
throw new LivingWorldError('effect-invalid', `Unknown problem ${effect.settles}`);
|
|
1088
|
+
// A vote called during play goes to the one already open in its room; a world's own data may hold several.
|
|
1089
|
+
const open = state.engine >= OPEN_NOW_ENGINE && event.kind !== 'opening'
|
|
1090
|
+
? (state.openDecisionIds ?? []).map(id => state.entities[id]).find(other => other?.placeId === effect.placeId) : undefined;
|
|
1091
|
+
if (open)
|
|
1092
|
+
throw new LivingWorldError('effect-invalid', `${open.name} is already open to votes here; cast votes on it, not on a new one`);
|
|
1093
|
+
}
|
|
1094
|
+
// A world's data may say who knows one of its things; they, its holder and its members do.
|
|
1095
|
+
if ((effect.knownTo !== undefined || effect.hidden) && !opening)
|
|
1096
|
+
throw new LivingWorldError('effect-invalid', 'Only a world\'s opening says who knows a thing, or hides it');
|
|
1097
|
+
for (const knower of effect.knownTo ?? [])
|
|
1098
|
+
requirePerson(state, knower);
|
|
1099
|
+
const knowers = effect.knownTo === undefined ? knownTo
|
|
1100
|
+
: [...new Set([...effect.knownTo, ...(effect.holderId === undefined ? [] : [effect.holderId]), ...(effect.memberIds ?? [])])].sort();
|
|
1101
|
+
state.entities[effect.entityId] = { id: effect.entityId, kindId: effect.kindId, name: effect.name, placeId: effect.placeId, holderId: effect.holderId ?? null,
|
|
1102
|
+
condition: effect.condition ?? null, properties: {}, relations: {}, introducedSeq: event.seq, knownTo: knowers, ...(effect.hidden ? { hidden: true } : {}),
|
|
1103
|
+
...(deciding ? { members: [...new Set(effect.memberIds)], ballots: {}, ...(effect.settles === undefined ? {} : { settles: effect.settles }),
|
|
1104
|
+
...(effect.transfers === undefined ? {} : { transfers: effect.transfers }) } : {}) };
|
|
1105
|
+
if (deciding) {
|
|
1106
|
+
state.entities[effect.entityId].condition = withSide(state.entities[effect.entityId], 'votes', tally(state.entities[effect.entityId]), state.engine >= WHOLE_SIDES_ENGINE, state.engine >= SIDE_ORDER_ENGINE);
|
|
1107
|
+
state.openDecisionIds = [...(state.openDecisionIds ?? []), effect.entityId];
|
|
1108
|
+
}
|
|
1109
|
+
if (effect.placeId === null)
|
|
1110
|
+
state.globalEntityIds.push(effect.entityId);
|
|
1111
|
+
else
|
|
1112
|
+
(state.entityPlaceIds[effect.placeId] ??= []).push(effect.entityId);
|
|
1113
|
+
indexPlaceless(state, effect.entityId);
|
|
1114
|
+
// A paper made sealed is sealed after its words are written.
|
|
1115
|
+
for (const [propertyId, value] of Object.entries(effect.initialProperties ?? {}).sort(([left], [right]) => Number(left === 'sealed') - Number(right === 'sealed'))) {
|
|
1116
|
+
applyEffect(state, event, { kind: 'property.set', entityId: effect.entityId, propertyId, value });
|
|
1117
|
+
}
|
|
1118
|
+
// From engine 50 a vote called during play without a closing time closes two game hours after it opens.
|
|
1119
|
+
const called = deciding && event.kind !== 'opening' ? readEntity(state, effect.entityId) : undefined;
|
|
1120
|
+
if (called && state.engine >= VOTE_CLOSES_ENGINE && called.properties['closes-at'] === undefined) {
|
|
1121
|
+
const opensAt = called.properties['opens-at']?.value;
|
|
1122
|
+
applyEffect(state, event, { kind: 'property.set', entityId: effect.entityId, propertyId: 'closes-at',
|
|
1123
|
+
value: (typeof opensAt === 'number' && opensAt > event.at ? opensAt : event.at) + VOTE_OPEN_MINUTES });
|
|
1124
|
+
}
|
|
1125
|
+
// A vote called during play with no later opening time opens now, so each member gets a turn at once.
|
|
1126
|
+
if (deciding && state.engine >= OPEN_NOW_ENGINE && event.kind !== 'opening'
|
|
1127
|
+
&& !state.schedule.some(item => item.count?.entityId === effect.entityId && item.count.opens)) {
|
|
1128
|
+
insertScheduled(state, { id: generatedId(state.engine, 'opens', [event.seq, effect.entityId], `opens-${event.seq}-${effect.entityId}`.slice(0, 64)), at: event.at, personId: null, kind: 'count',
|
|
1129
|
+
intent: `Voting opens on ${effect.name}`.slice(0, MAX_LINE), placeId: effect.placeId, targetId: null, sourceSeq: event.seq, importance: 5,
|
|
1130
|
+
count: { entityId: effect.entityId, opens: true } });
|
|
1131
|
+
}
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
case 'entity.move': {
|
|
1135
|
+
const entity = state.entities[effect.entityId];
|
|
1136
|
+
if (!entity)
|
|
1137
|
+
throw new LivingWorldError('effect-invalid', `Unknown entity ${effect.entityId}`);
|
|
1138
|
+
if (entity.placeId === null)
|
|
1139
|
+
throw new LivingWorldError('effect-invalid', `${entity.name} is nowhere to be held; only a thing that is somewhere can change hands`);
|
|
1140
|
+
if ((effect.holderId === null) === (effect.placeId === null))
|
|
1141
|
+
throw new LivingWorldError('effect-invalid', `${entity.name} goes either to a holder or to a place`);
|
|
1142
|
+
const at = state.places[entity.placeId]?.name ?? entity.placeId;
|
|
1143
|
+
if (effect.holderId !== null && state.engine >= UNHELD_ENGINE && neverHeld(state.engine, entity.kindId, state.kinds[entity.kindId])) {
|
|
1144
|
+
throw new LivingWorldError('effect-invalid', `${entity.name} stays where it is; no one holds it`);
|
|
1145
|
+
}
|
|
1146
|
+
if (effect.holderId !== null) {
|
|
1147
|
+
const holder = requirePerson(state, effect.holderId);
|
|
1148
|
+
if (holder.departed || holder.away || holder.placeId !== entity.placeId) {
|
|
1149
|
+
throw new LivingWorldError('effect-invalid', `${entity.name} is in ${at}, not with ${holder.name}`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
if (effect.placeId !== null && effect.placeId !== entity.placeId)
|
|
1153
|
+
throw new LivingWorldError('effect-invalid', `${entity.name} can only be set down in ${at}, where it is`);
|
|
1154
|
+
entity.holderId = effect.holderId;
|
|
1155
|
+
entity.knownTo = [...new Set([...entity.knownTo, ...(event.perceivedBy ?? event.witnessIds), ...(event.actorId ? [event.actorId] : [])])];
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
case 'entity.condition': {
|
|
1159
|
+
const entity = state.entities[effect.entityId];
|
|
1160
|
+
if (!entity)
|
|
1161
|
+
throw new LivingWorldError('effect-invalid', `Unknown entity ${effect.entityId}`);
|
|
1162
|
+
if (entity.placeId === null)
|
|
1163
|
+
throw new LivingWorldError('effect-invalid', `${entity.name} is nowhere to be seen; only a thing that is somewhere shows a condition`);
|
|
1164
|
+
if (entity.kindId === 'decision' && state.engine >= COUNT_ONLY_ENGINE) {
|
|
1165
|
+
throw new LivingWorldError('effect-invalid', `The count on ${entity.name} is the engine's; a member casts a ballot, and the vote closes at its time or with decision.close`);
|
|
1166
|
+
}
|
|
1167
|
+
entity.condition = state.engine >= CONDITION_SIDES_ENGINE ? withSide(entity, effect.aspect, effect.condition, state.engine >= WHOLE_SIDES_ENGINE, state.engine >= SIDE_ORDER_ENGINE) : effect.condition;
|
|
1168
|
+
entity.knownTo = [...new Set([...entity.knownTo, ...(event.perceivedBy ?? event.witnessIds), ...(event.actorId ? [event.actorId] : [])])];
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
case 'property.set': {
|
|
1172
|
+
const entity = state.entities[effect.entityId];
|
|
1173
|
+
if (!entity)
|
|
1174
|
+
throw new LivingWorldError('effect-invalid', `Unknown entity ${effect.entityId}`);
|
|
1175
|
+
const definition = state.kinds[entity.kindId];
|
|
1176
|
+
const property = definition.properties.find(slot => slot.id === effect.propertyId);
|
|
1177
|
+
if (!property)
|
|
1178
|
+
throw new LivingWorldError('effect-invalid', `Unknown property ${effect.propertyId}`);
|
|
1179
|
+
const authority = definition.mechanics.filter((mechanic) => mechanic.kind === 'authority' && mechanic.propertyId === effect.propertyId);
|
|
1180
|
+
if (event.actorId !== null && authority.length > 0 && !authority.some(mechanic => state.people[event.actorId]?.roleId === mechanic.roleId)) {
|
|
1181
|
+
throw new LivingWorldError('effect-invalid', `Changing ${effect.propertyId} needs authority role ${authority.map(mechanic => mechanic.roleId).join(' or ')}`);
|
|
1182
|
+
}
|
|
1183
|
+
if (effect.value !== null && !valueMatchesType(property.type, effect.value)) {
|
|
1184
|
+
throw new LivingWorldError('effect-invalid', `Property ${effect.propertyId} needs a ${property.type} value`);
|
|
1185
|
+
}
|
|
1186
|
+
if (effect.value !== null && property.type === 'id' && !readEntity(state, String(effect.value))
|
|
1187
|
+
&& !state.people[String(effect.value)] && !state.places[String(effect.value)] && !state.objects[String(effect.value)]) {
|
|
1188
|
+
throw new LivingWorldError('effect-invalid', `Property ${effect.propertyId} refers to an unknown entity`);
|
|
1189
|
+
}
|
|
1190
|
+
// A decision's terms are fixed once anyone has voted on it.
|
|
1191
|
+
if (entity.members !== undefined && (Object.keys(entity.ballots ?? {}).length > 0 || entity.result !== undefined)) {
|
|
1192
|
+
throw new LivingWorldError('effect-invalid', `${entity.name} is being voted on; its terms no longer change`);
|
|
1193
|
+
}
|
|
1194
|
+
if (effect.propertyId !== 'sealed' && sealedPaper(state.engine, entity)) {
|
|
1195
|
+
throw new LivingWorldError('effect-invalid', `${entity.name} is sealed; open it first, by setting sealed to false`);
|
|
1196
|
+
}
|
|
1197
|
+
if (effect.value !== null && namesPerson(state.engine, entity.kindId, effect.propertyId) && !state.people[String(effect.value)]) {
|
|
1198
|
+
throw new LivingWorldError('effect-invalid', `The ${effect.propertyId} of ${definition.name} ${entity.name} names a person, and ${String(effect.value)} is not one`);
|
|
1199
|
+
}
|
|
1200
|
+
if (effect.value !== null && oneEachSlot(state.engine, entity.kindId) === effect.propertyId) {
|
|
1201
|
+
const other = Object.values(state.entities).find(candidate => candidate.id !== entity.id && candidate.kindId === entity.kindId
|
|
1202
|
+
&& candidate.properties[effect.propertyId]?.value === effect.value);
|
|
1203
|
+
if (other)
|
|
1204
|
+
throw new LivingWorldError('effect-invalid', `${state.people[String(effect.value)]?.name ?? effect.value} already has ${definition.name} ${other.name}: change that one instead`);
|
|
1205
|
+
}
|
|
1206
|
+
const previousSeq = entity.properties[effect.propertyId]?.changedSeq ?? null;
|
|
1207
|
+
const previousValue = state.engine < LAZY_METER_ENGINE ? entity.properties[effect.propertyId]?.value : currentValue(state, entity, effect.propertyId, event.at);
|
|
1208
|
+
if (state.engine >= LAZY_METER_ENGINE)
|
|
1209
|
+
keepPassedCrossings(state, entity.id, effect.propertyId, event.at);
|
|
1210
|
+
for (const mechanic of definition.mechanics) {
|
|
1211
|
+
if (mechanic.kind !== 'change' || mechanic.onEvent?.subjectPropertyId !== effect.propertyId)
|
|
1212
|
+
continue;
|
|
1213
|
+
if (typeof previousValue === 'string')
|
|
1214
|
+
state.entityMoveSubjectIds[previousValue] =
|
|
1215
|
+
(state.entityMoveSubjectIds[previousValue] ?? []).filter(id => id !== entity.id);
|
|
1216
|
+
if (typeof effect.value === 'string')
|
|
1217
|
+
(state.entityMoveSubjectIds[effect.value] ??= []).push(entity.id);
|
|
1218
|
+
}
|
|
1219
|
+
const previousRefs = property.type === 'id' && entity.placeId === null ? entityRefs(state.kinds, entity) : null;
|
|
1220
|
+
// Before engine 45, writing on a paper adds to what it holds, marked with when and by whom.
|
|
1221
|
+
const written = entity.properties[effect.propertyId]?.value;
|
|
1222
|
+
const wholeText = state.engine >= WHOLE_TEXT_ENGINE;
|
|
1223
|
+
if (!wholeText && state.engine >= ADDED_LINE_ENGINE && property.type === 'text' && typeof effect.value === 'string' && typeof written === 'string' && written !== ''
|
|
1224
|
+
&& effect.value.length > MAX_ADDED_LINE) {
|
|
1225
|
+
throw new LivingWorldError('effect-invalid', `Add only the words written now, at most ${MAX_ADDED_LINE} characters, to ${entity.name}; what is already written stays on it`);
|
|
1226
|
+
}
|
|
1227
|
+
// The whole text as it now reads replaces the old; one that drops most of a long text is no rewrite of it.
|
|
1228
|
+
if (wholeText && property.type === 'text' && typeof effect.value === 'string' && typeof written === 'string' && written.length > MAX_ADDED_LINE
|
|
1229
|
+
&& effect.value.length * 2 < written.length) {
|
|
1230
|
+
throw new LivingWorldError('effect-invalid', `Write the whole text of ${entity.name} as it now reads, with the change; to erase it, set it to null`);
|
|
1231
|
+
}
|
|
1232
|
+
const stored = !wholeText && state.engine >= WRITING_GROWS_ENGINE && property.type === 'text' && typeof effect.value === 'string' && typeof written === 'string' && written !== ''
|
|
1233
|
+
? addWriting(written, `[${clockLabel(event.at)}${event.actorId && state.people[event.actorId] ? `, ${state.people[event.actorId].name}` : ''}] ${effect.value}`)
|
|
1234
|
+
: effect.value;
|
|
1235
|
+
if (effect.propertyId === 'body' && property.type === 'text') {
|
|
1236
|
+
const prior = entity.properties.body;
|
|
1237
|
+
const views = { ...entity.paperViews };
|
|
1238
|
+
for (const reader of prior?.knownTo ?? [])
|
|
1239
|
+
views[reader] = { name: entity.name, words: typeof prior?.value === 'string' ? prior.value : null };
|
|
1240
|
+
for (const reader of opening ? entity.knownTo : knownTo)
|
|
1241
|
+
views[reader] = { name: entity.name, words: typeof stored === 'string' ? stored : null };
|
|
1242
|
+
entity.paperViews = views;
|
|
1243
|
+
}
|
|
1244
|
+
if (stored === null)
|
|
1245
|
+
delete entity.properties[effect.propertyId];
|
|
1246
|
+
// A world's own words are known to whoever knows the thing; the slot shares its list, which is never changed in place.
|
|
1247
|
+
else
|
|
1248
|
+
entity.properties[effect.propertyId] = { value: stored, changedSeq: event.seq, previousSeq, knownTo: opening ? entity.knownTo : knownTo, updatedAt: event.at };
|
|
1249
|
+
if (previousRefs !== null)
|
|
1250
|
+
reindexPlaceless(state, entity.id, previousRefs);
|
|
1251
|
+
state.changes.push({ seq: event.seq, entityId: entity.id, slotKind: 'property', slotId: effect.propertyId, value: stored });
|
|
1252
|
+
if (effect.value !== null && typeof effect.value === 'string' && ['none', 'low', 'medium', 'high'].includes(effect.value)) {
|
|
1253
|
+
spreadLevel(state, entity.id, effect.propertyId, effect.value, event.seq, event.at);
|
|
1254
|
+
}
|
|
1255
|
+
if (typeof effect.value === 'number')
|
|
1256
|
+
runTriggers(state, entity.id, effect.propertyId, typeof previousValue === 'number' ? previousValue : null, effect.value, event.at, event.seq);
|
|
1257
|
+
if (state.engine >= LAZY_METER_ENGINE)
|
|
1258
|
+
predictCrossings(state, entity.id, effect.propertyId, event.seq);
|
|
1259
|
+
if (entity.members !== undefined && (effect.propertyId === 'opens-at' || effect.propertyId === 'closes-at'))
|
|
1260
|
+
setCount(state, event, entity.id, effect.propertyId === 'opens-at', typeof stored === 'number' ? stored : null);
|
|
1261
|
+
if (effect.propertyId === 'author' && state.engine >= AUTHORS_ENGINE)
|
|
1262
|
+
noteAuthor(state, entity.id, typeof previousValue === 'string' ? previousValue : null, typeof stored === 'string' ? stored : null);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
case 'ballot': {
|
|
1266
|
+
const decision = state.entities[effect.entityId];
|
|
1267
|
+
if (!decision || decision.members === undefined)
|
|
1268
|
+
throw new LivingWorldError('effect-invalid', `${decision?.name ?? effect.entityId} is not put to a vote`);
|
|
1269
|
+
const voter = requirePerson(state, effect.personId);
|
|
1270
|
+
const shut = votingShut(state, decision, event.at);
|
|
1271
|
+
if (shut)
|
|
1272
|
+
throw new LivingWorldError('effect-invalid', `${decision.name} ${shut}`);
|
|
1273
|
+
if (!decision.members.includes(voter.id))
|
|
1274
|
+
throw new LivingWorldError('effect-invalid', `${voter.name} is not a member voting on ${decision.name}`);
|
|
1275
|
+
if (event.actorId !== voter.id)
|
|
1276
|
+
throw new LivingWorldError('effect-invalid', `Only ${voter.name} casts ${voter.name}'s vote on ${decision.name}`);
|
|
1277
|
+
if (decision.ballots?.[voter.id] !== undefined)
|
|
1278
|
+
throw new LivingWorldError('effect-invalid', `${voter.name} has already voted on ${decision.name}`);
|
|
1279
|
+
decision.ballots = { ...decision.ballots, [voter.id]: effect.choice };
|
|
1280
|
+
decision.condition = withSide(decision, 'votes', tally(decision), state.engine >= WHOLE_SIDES_ENGINE, state.engine >= SIDE_ORDER_ENGINE);
|
|
1281
|
+
decision.knownTo = [...new Set([...decision.knownTo, ...(event.perceivedBy ?? event.witnessIds), voter.id])];
|
|
1282
|
+
// The last member's vote closes it.
|
|
1283
|
+
if (decision.members.every(memberId => decision.ballots[memberId] !== undefined))
|
|
1284
|
+
closeDecision(state, event, decision);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
case 'decision.close': {
|
|
1288
|
+
const decision = state.entities[effect.entityId];
|
|
1289
|
+
if (!decision || decision.members === undefined || decision.result !== undefined)
|
|
1290
|
+
throw new LivingWorldError('effect-invalid', `${decision?.name ?? effect.entityId} is not an open vote`);
|
|
1291
|
+
const closesAt = decision.properties['closes-at']?.value;
|
|
1292
|
+
if (typeof closesAt !== 'number' || closesAt > event.at)
|
|
1293
|
+
throw new LivingWorldError('effect-invalid', `${decision.name} is not yet due to be counted`);
|
|
1294
|
+
if (effect.result !== countResult(decision))
|
|
1295
|
+
throw new LivingWorldError('effect-invalid', `${decision.name} counts as ${countResult(decision)}, not ${effect.result}`);
|
|
1296
|
+
closeDecision(state, event, decision);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
case 'clock': {
|
|
1300
|
+
// The clock is set at the game time it had reached; from then on it runs at the new rate. From
|
|
1301
|
+
// engine 40 that is where the player could see it, a short lead past what the world simulated:
|
|
1302
|
+
// time it never reached is not owed afterwards.
|
|
1303
|
+
const target = clockTargetAt(state, event.realAt);
|
|
1304
|
+
const shown = state.engine >= SHOWN_CLOCK_ENGINE && state.clockRate !== null ? Math.min(target, state.clock + livingClockLead(state.clockRate)) : target;
|
|
1305
|
+
const game = Math.max(state.clock, shown);
|
|
1306
|
+
state.clockAnchor = { game, real: event.realAt };
|
|
1307
|
+
state.clockRate = effect.rate;
|
|
1308
|
+
state.paused = effect.paused;
|
|
1309
|
+
// Once the clock runs, every two game hours the open problem nobody has acted on longest goes on by itself.
|
|
1310
|
+
if (state.engine >= STALL_ENGINE && state.clockRate !== null && !state.schedule.some(item => item.series === 'stall-check')) {
|
|
1311
|
+
insertScheduled(state, { id: 'stall-check', at: state.clock + STALL_MINUTES, personId: null, kind: 'world', intent: 'An open problem nobody has acted on goes on by itself.',
|
|
1312
|
+
placeId: state.people[state.playerId].placeId, targetId: null, sourceSeq: event.seq, importance: 3, everyMinutes: STALL_MINUTES, series: 'stall-check' });
|
|
1313
|
+
}
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
case 'relation.set': {
|
|
1317
|
+
const entity = state.entities[effect.entityId];
|
|
1318
|
+
if (!entity)
|
|
1319
|
+
throw new LivingWorldError('effect-invalid', `Unknown entity ${effect.entityId}`);
|
|
1320
|
+
const definition = state.kinds[entity.kindId];
|
|
1321
|
+
const relation = definition.relations.find(slot => slot.id === effect.relationId);
|
|
1322
|
+
if (!relation)
|
|
1323
|
+
throw new LivingWorldError('effect-invalid', `Unknown relation ${effect.relationId}`);
|
|
1324
|
+
const authority = definition.mechanics.filter((mechanic) => mechanic.kind === 'authority' && mechanic.relationId === effect.relationId);
|
|
1325
|
+
if (event.actorId !== null && authority.length > 0 && !authority.some(mechanic => state.people[event.actorId]?.roleId === mechanic.roleId)) {
|
|
1326
|
+
throw new LivingWorldError('effect-invalid', `Changing ${effect.relationId} needs authority role ${authority.map(mechanic => mechanic.roleId).join(' or ')}`);
|
|
1327
|
+
}
|
|
1328
|
+
if (effect.targetId !== null && readEntity(state, effect.targetId)?.kindId !== relation.targetKindId) {
|
|
1329
|
+
throw new LivingWorldError('effect-invalid', `Relation ${effect.relationId} needs a ${relation.targetKindId} target`);
|
|
1330
|
+
}
|
|
1331
|
+
const previousSeq = entity.relations[effect.relationId]?.changedSeq ?? null;
|
|
1332
|
+
const previousRefs = entity.placeId === null ? entityRefs(state.kinds, entity) : null;
|
|
1333
|
+
if (effect.targetId === null)
|
|
1334
|
+
delete entity.relations[effect.relationId];
|
|
1335
|
+
else
|
|
1336
|
+
entity.relations[effect.relationId] = { targetId: effect.targetId, changedSeq: event.seq, previousSeq, knownTo: opening ? entity.knownTo : knownTo };
|
|
1337
|
+
if (previousRefs !== null)
|
|
1338
|
+
reindexPlaceless(state, entity.id, previousRefs);
|
|
1339
|
+
state.changes.push({ seq: event.seq, entityId: entity.id, slotKind: 'relation', slotId: effect.relationId, value: effect.targetId });
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
// What the player reads of a problem is what they last saw of it, with its time. A problem they did not
|
|
1345
|
+
// see begin is named by the place where they saw it (before engine 45, by the moment itself); a change
|
|
1346
|
+
// they see is what they read of it from then on; anything else they see of it is its latest development.
|
|
1347
|
+
function seeTension(state, tension, event, seen) {
|
|
1348
|
+
const line = observedSummary(event, state.playerId) ?? tension.summary;
|
|
1349
|
+
const resolution = tension.status === 'settled' ? event.observed ?? tension.resolution : null;
|
|
1350
|
+
const view = tension.playerView;
|
|
1351
|
+
if (view === null || seen !== 'shown') {
|
|
1352
|
+
const place = event.placeId === null ? undefined : state.places[event.placeId];
|
|
1353
|
+
const where = state.engine >= SEEN_WHERE_ENGINE && place ? `Seen in ${place.name}` : line;
|
|
1354
|
+
tension.playerView = { title: view?.title ?? (seen === 'created' ? tension.title : where).slice(0, 200), summary: line, status: tension.status, resolution, latest: null, since: event.at };
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
tension.playerView = { ...view, status: tension.status, resolution, latest: line, latestAt: event.at };
|
|
1358
|
+
}
|
|
1359
|
+
function rememberEvent(state, event) {
|
|
1360
|
+
// The opening is the situation everyone already lives in, not a memory of something happening.
|
|
1361
|
+
if (event.kind === 'opening' || event.kind === 'keep-playing' || event.kind === 'finish' || event.kind === 'wait')
|
|
1362
|
+
return;
|
|
1363
|
+
const spoken = event.effects.find((effect) => effect.kind === 'say' && effect.personId === event.actorId);
|
|
1364
|
+
for (const witnessId of event.witnessIds) {
|
|
1365
|
+
const witness = state.people[witnessId];
|
|
1366
|
+
if (!witness || witness.isPlayer)
|
|
1367
|
+
continue;
|
|
1368
|
+
if (event.perceivedBy && !event.perceivedBy.includes(witnessId) && event.actorId !== witnessId)
|
|
1369
|
+
continue;
|
|
1370
|
+
// From engine 42 one's own words are remembered as one's own, whoever else they named.
|
|
1371
|
+
const mine = spoken && state.engine >= IDENTITY_ENGINE
|
|
1372
|
+
? `I said${spoken.toIds.length === 0 ? '' : ` to ${spoken.toIds.map(id => state.people[id]?.name ?? id).join(' and ')}`}: "${spoken.text}"` : null;
|
|
1373
|
+
const memory = event.actorId === witnessId
|
|
1374
|
+
? mine ?? `I did this: ${event.summary}`
|
|
1375
|
+
: spoken
|
|
1376
|
+
? `I heard ${state.people[spoken.personId].name} say: "${spoken.text}"`
|
|
1377
|
+
: `I was there and saw this: ${event.observed ?? event.summary}`;
|
|
1378
|
+
addMemory(state, witnessId, event, memory, event.importance, event.witnessIds.filter(other => other !== witnessId), { kind: spoken ? 'heard' : 'observed', eventSeq: event.seq, sourceId: spoken?.personId ?? null });
|
|
1379
|
+
}
|
|
1380
|
+
for (const nearbyId of event.nearbyIds) {
|
|
1381
|
+
const person = state.people[nearbyId];
|
|
1382
|
+
if (!person || person.isPlayer)
|
|
1383
|
+
continue;
|
|
1384
|
+
// Someone nearby remembers only what reached them, and where they were when it did;
|
|
1385
|
+
// someone who walked in is simply seen arriving.
|
|
1386
|
+
const arrival = arrivalFor(state, event, nearbyId);
|
|
1387
|
+
if (arrival) {
|
|
1388
|
+
addMemory(state, nearbyId, event, `${arrival}.`, 1, [], { kind: 'observed', eventSeq: event.seq, sourceId: null });
|
|
1389
|
+
continue;
|
|
1390
|
+
}
|
|
1391
|
+
const where = state.places[person.placeId]?.name ?? person.placeId;
|
|
1392
|
+
const from = event.placeId ? state.places[event.placeId]?.name ?? event.placeId : 'somewhere close';
|
|
1393
|
+
const signal = signalsFor(state, event.effects).find(item => item.targetPlaceId === person.placeId || item.channel === GLOBAL_SIGNAL_CHANNEL);
|
|
1394
|
+
addMemory(state, nearbyId, event, `From ${where}, I noticed something from ${from}: ${event.notice ?? 'a commotion'} (I did not see what happened.)`, Math.max(1, event.importance - 1), [], { kind: signal ? signal.channel === 'sound' || signal.channel === GLOBAL_SIGNAL_CHANNEL ? 'heard' : 'observed' : 'inferred', eventSeq: event.seq, sourceId: null });
|
|
1395
|
+
}
|
|
1396
|
+
for (const effect of event.effects) {
|
|
1397
|
+
if (effect.kind !== 'message')
|
|
1398
|
+
continue;
|
|
1399
|
+
for (const recipient of effect.toIds) {
|
|
1400
|
+
const person = state.people[recipient];
|
|
1401
|
+
if (!person || person.isPlayer)
|
|
1402
|
+
continue;
|
|
1403
|
+
addMemory(state, recipient, event, `${state.people[effect.fromId]?.name ?? effect.fromId} sent me a message: "${effect.text}"`, Math.max(3, event.importance), [effect.fromId], { kind: 'told', eventSeq: event.seq, sourceId: effect.fromId });
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
// A message, sent at once or handed on to travel, needs a sender free to send it, other people to
|
|
1408
|
+
// read it, and a recorded account from anyone it passes on.
|
|
1409
|
+
function checkMessage(state, event, effect) {
|
|
1410
|
+
requirePerson(state, effect.fromId);
|
|
1411
|
+
if (state.engine >= ACT_GATE_ENGINE && event.actorId === effect.fromId) {
|
|
1412
|
+
const gate = blockedByGate(state, effect.fromId, 'message');
|
|
1413
|
+
if (gate)
|
|
1414
|
+
throw new LivingWorldError('effect-invalid', `Messages are blocked by ${gate}`);
|
|
1415
|
+
}
|
|
1416
|
+
for (const target of effect.toIds)
|
|
1417
|
+
requirePerson(state, target);
|
|
1418
|
+
if (effect.toIds.includes(effect.fromId))
|
|
1419
|
+
throw new LivingWorldError('effect-invalid', 'A message needs another recipient');
|
|
1420
|
+
if (effect.sourcePersonId && effect.sourcePersonId !== effect.fromId) {
|
|
1421
|
+
requirePerson(state, effect.sourcePersonId);
|
|
1422
|
+
const contacted = state.messages.some(message => message.fromId === effect.sourcePersonId && message.toIds.includes(effect.fromId))
|
|
1423
|
+
|| state.conversations.some(conversation => conversation.lines.some(line => line.speakerId === effect.sourcePersonId && line.toIds.includes(effect.fromId)));
|
|
1424
|
+
if (!contacted)
|
|
1425
|
+
throw new LivingWorldError('effect-invalid', `${effect.fromId} has no recorded account from ${effect.sourcePersonId}`);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
// The player's own choices and what they perceived that mattered stay for the ending however long
|
|
1429
|
+
// the story runs; past the limit the least weighty of the older half makes room.
|
|
1430
|
+
function recordChronicle(state, event) {
|
|
1431
|
+
if (state.engine < CHRONICLE_ENGINE || !visibleTo(event, state.playerId))
|
|
1432
|
+
return;
|
|
1433
|
+
const mine = event.actorId === state.playerId && (event.kind === 'speech' || event.kind === 'message' || event.kind === 'attempt');
|
|
1434
|
+
if (!mine && !(event.importance >= 6 || event.kind === 'attempt' || event.kind === 'world'))
|
|
1435
|
+
return;
|
|
1436
|
+
const summary = observedSummary(event, state.playerId);
|
|
1437
|
+
if (summary === null)
|
|
1438
|
+
return;
|
|
1439
|
+
const nearbyOnly = event.nearbyIds.includes(state.playerId) && !witnessed(event, state.playerId) && event.actorId !== state.playerId;
|
|
1440
|
+
state.chronicle.push({ seq: event.seq, at: event.at, kind: event.kind, placeId: nearbyOnly ? null : event.placeId, summary: summary.slice(0, 2000),
|
|
1441
|
+
outcome: event.observed === undefined ? event.outcome : null, importance: event.importance, mine });
|
|
1442
|
+
if (state.chronicle.length <= MAX_CHRONICLE)
|
|
1443
|
+
return;
|
|
1444
|
+
const older = Math.floor(state.chronicle.length / 2);
|
|
1445
|
+
let drop = 0;
|
|
1446
|
+
for (let index = 1; index < older; index += 1)
|
|
1447
|
+
if (state.chronicle[index].importance < state.chronicle[drop].importance)
|
|
1448
|
+
drop = index;
|
|
1449
|
+
state.chronicle.splice(drop, 1);
|
|
1450
|
+
}
|
|
1451
|
+
function addMemory(state, personId, event, text, importance, participantIds, evidence = { kind: 'inferred', eventSeq: event.seq, sourceId: null }) {
|
|
1452
|
+
const person = state.people[personId];
|
|
1453
|
+
person.memoryCount = (person.memoryCount ?? person.memories.length) + 1;
|
|
1454
|
+
const thingIds = state.engine >= THING_MEMORY_ENGINE ? thingsOf(event) : [];
|
|
1455
|
+
const memory = {
|
|
1456
|
+
id: generatedId(state.engine, 'memory', [personId, event.seq, person.memoryCount], `${personId}-memory-${event.seq}-${person.memoryCount}`), at: event.at, eventSeq: event.seq,
|
|
1457
|
+
text: text.slice(0, 2000), importance, participantIds: [...new Set(participantIds)], placeId: event.placeId,
|
|
1458
|
+
...(thingIds.length === 0 ? {} : { thingIds }), evidence,
|
|
1459
|
+
};
|
|
1460
|
+
if (isDraft(person)) {
|
|
1461
|
+
// Memories are append-only. Skip drafting and walking the immutable archive.
|
|
1462
|
+
const memories = [...plain(person.memories), memory];
|
|
1463
|
+
Object.freeze(memories);
|
|
1464
|
+
person.memories = memories;
|
|
1465
|
+
}
|
|
1466
|
+
else
|
|
1467
|
+
person.memories.push(memory);
|
|
1468
|
+
}
|
|
1469
|
+
// A ruling leaves each thing it concerned a note of what it did with it, so a later ruling knows;
|
|
1470
|
+
// the oldest note makes room past the limit.
|
|
1471
|
+
function noteThings(state, event) {
|
|
1472
|
+
if (state.engine < THING_MEMORY_ENGINE || (event.kind !== 'attempt' && event.kind !== 'world'))
|
|
1473
|
+
return;
|
|
1474
|
+
const things = thingsOf(event).filter(id => readEntity(state, id) || state.objects[id]);
|
|
1475
|
+
if (things.length === 0)
|
|
1476
|
+
return;
|
|
1477
|
+
for (const id of things)
|
|
1478
|
+
state.thingNotes[id] = { seq: event.seq, at: event.at, text: event.summary.slice(0, THING_NOTE_TEXT) };
|
|
1479
|
+
const ids = Object.keys(state.thingNotes);
|
|
1480
|
+
if (ids.length <= MAX_THING_NOTES)
|
|
1481
|
+
return;
|
|
1482
|
+
for (const id of ids.sort((left, right) => state.thingNotes[left].seq - state.thingNotes[right].seq || (left < right ? -1 : left > right ? 1 : 0)).slice(0, ids.length - MAX_THING_NOTES))
|
|
1483
|
+
delete state.thingNotes[id];
|
|
1484
|
+
}
|
|
1485
|
+
// From engine 49 someone who comes to a place sees what lies there in the open, and how it plainly is,
|
|
1486
|
+
// but reads no words: what someone holds, what the world hides, and what is written, they learn only
|
|
1487
|
+
// by seeing it handled or by reading it.
|
|
1488
|
+
function seeWhatLiesHere(state, personId, placeId) {
|
|
1489
|
+
if (state.engine < THINGS_SEEN_ENGINE)
|
|
1490
|
+
return;
|
|
1491
|
+
for (const id of state.entityPlaceIds[placeId] ?? []) {
|
|
1492
|
+
// Looked at first, so a thing already known is not rewritten.
|
|
1493
|
+
const seen = readEntity(state, id);
|
|
1494
|
+
if (!seen || seen.holderId !== null || seen.hidden || seen.knownTo.includes(personId))
|
|
1495
|
+
continue;
|
|
1496
|
+
const entity = state.entities[id];
|
|
1497
|
+
entity.knownTo = [...entity.knownTo, personId];
|
|
1498
|
+
const types = state.kinds[entity.kindId]?.properties ?? [];
|
|
1499
|
+
for (const [propertyId, slot] of Object.entries(entity.properties)) {
|
|
1500
|
+
if (types.find(property => property.id === propertyId)?.type === 'text' || slot.knownTo.includes(personId))
|
|
1501
|
+
continue;
|
|
1502
|
+
entity.properties[propertyId] = { ...slot, knownTo: [...slot.knownTo, personId] };
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
// From engine 49 those who perceive an act know the things it concerned, and the one who acted on a
|
|
1507
|
+
// thing, such as by reading it, knows all of it, its words too, unless it is sealed.
|
|
1508
|
+
function learnThings(state, event) {
|
|
1509
|
+
// The opening says itself who knows each of the world's own things.
|
|
1510
|
+
if (state.engine < THINGS_SEEN_ENGINE || event.kind === 'opening')
|
|
1511
|
+
return;
|
|
1512
|
+
const perceivers = event.perceivedBy ?? event.witnessIds;
|
|
1513
|
+
const actor = event.kind === 'attempt' && event.actorId !== null && state.people[event.actorId] ? event.actorId : null;
|
|
1514
|
+
const knowers = [...perceivers, ...(actor === null ? [] : [actor])];
|
|
1515
|
+
for (const id of thingsOf(event)) {
|
|
1516
|
+
// Looked at first, so a thing already known as it is, is not rewritten.
|
|
1517
|
+
const seen = readEntity(state, id);
|
|
1518
|
+
if (!seen)
|
|
1519
|
+
continue;
|
|
1520
|
+
const sealed = sealedPaper(state.engine, seen);
|
|
1521
|
+
const types = state.kinds[seen.kindId]?.properties ?? [];
|
|
1522
|
+
const unread = (propertyId, slot) => actor !== null && !slot.knownTo.includes(actor)
|
|
1523
|
+
&& !(sealed && types.find(property => property.id === propertyId)?.type === 'text');
|
|
1524
|
+
const learns = knowers.some(knower => !seen.knownTo.includes(knower)) || Object.entries(seen.properties).some(([propertyId, slot]) => unread(propertyId, slot));
|
|
1525
|
+
if (!learns)
|
|
1526
|
+
continue;
|
|
1527
|
+
const entity = state.entities[id];
|
|
1528
|
+
if (knowers.some(knower => !entity.knownTo.includes(knower)))
|
|
1529
|
+
entity.knownTo = [...new Set([...entity.knownTo, ...knowers])];
|
|
1530
|
+
for (const [propertyId, slot] of Object.entries(entity.properties)) {
|
|
1531
|
+
if (unread(propertyId, slot))
|
|
1532
|
+
entity.properties[propertyId] = { ...slot, knownTo: [...slot.knownTo, actor] };
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
// The things an event concerned: those it names and those its changes touch.
|
|
1537
|
+
function thingsOf(event) {
|
|
1538
|
+
const touched = event.effects.flatMap(effect => effect.kind === 'object.update' || effect.kind === 'object.create' ? [effect.objectId]
|
|
1539
|
+
: effect.kind === 'entity.create' || effect.kind === 'property.set' || effect.kind === 'entity.move' || effect.kind === 'entity.condition' || effect.kind === 'relation.set'
|
|
1540
|
+
? [effect.entityId] : []);
|
|
1541
|
+
return [...new Set([...(event.thingIds ?? []), ...touched])].slice(0, 8);
|
|
1542
|
+
}
|
|
1543
|
+
// What a person holds goes with them: each thing they carry is now where they are.
|
|
1544
|
+
function carryHeldEntities(state, personId, from, to) {
|
|
1545
|
+
const carried = (state.entityPlaceIds[from] ?? []).filter(id => readEntity(state, id)?.holderId === personId);
|
|
1546
|
+
if (carried.length === 0)
|
|
1547
|
+
return;
|
|
1548
|
+
// A place with nothing left has no list, as when a save is loaded.
|
|
1549
|
+
const left = state.entityPlaceIds[from].filter(id => !carried.includes(id));
|
|
1550
|
+
if (left.length > 0)
|
|
1551
|
+
state.entityPlaceIds[from] = left;
|
|
1552
|
+
else
|
|
1553
|
+
delete state.entityPlaceIds[from];
|
|
1554
|
+
for (const id of carried) {
|
|
1555
|
+
state.entities[id].placeId = to;
|
|
1556
|
+
(state.entityPlaceIds[to] ??= []).push(id);
|
|
1557
|
+
}
|
|
1558
|
+
if (plain(state.meterSchedule).some(item => item.targetId !== null && carried.includes(item.targetId))) {
|
|
1559
|
+
state.meterSchedule = (isDraft(state.meterSchedule) ? current(state.meterSchedule) : state.meterSchedule)
|
|
1560
|
+
.map(item => item.targetId !== null && carried.includes(item.targetId) ? { ...item, placeId: to } : item);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
// The player learns where people are by seeing them. Looking at a room also shows who is
|
|
1564
|
+
// no longer there: they stay known, last seen there, with their whereabouts now unknown.
|
|
1565
|
+
function observePlace(state, placeId, at) {
|
|
1566
|
+
for (const person of Object.values(state.people)) {
|
|
1567
|
+
if (person.isPlayer)
|
|
1568
|
+
continue;
|
|
1569
|
+
const sighting = state.sightings[person.id];
|
|
1570
|
+
const gone = person.away !== undefined || state.engine >= LEFT_UNSEEN_ENGINE && person.departed !== undefined;
|
|
1571
|
+
if (person.placeId === placeId && !gone)
|
|
1572
|
+
state.sightings[person.id] = { placeId, at };
|
|
1573
|
+
else if (sighting?.placeId === placeId && !sighting.gone)
|
|
1574
|
+
state.sightings[person.id] = { ...sighting, gone: true, ...(state.engine >= GONE_AT_ENGINE ? { goneAt: at } : {}) };
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
function leaveConversations(state, personId, placeId, at) {
|
|
1578
|
+
for (const conversation of state.conversations) {
|
|
1579
|
+
if (conversation.endedAt !== null || conversation.placeId !== placeId || !conversation.participantIds.includes(personId))
|
|
1580
|
+
continue;
|
|
1581
|
+
const remaining = conversation.participantIds.filter(id => id !== personId);
|
|
1582
|
+
// A conversation always keeps at least its last participant on record; it ends when one person is left.
|
|
1583
|
+
if (remaining.length > 0)
|
|
1584
|
+
conversation.participantIds = remaining;
|
|
1585
|
+
if (remaining.length < 2)
|
|
1586
|
+
conversation.endedAt = at;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
function openConversation(state, placeId, participantIds, at, seq) {
|
|
1590
|
+
const existing = state.conversations.find(conversation => conversation.endedAt === null && conversation.placeId === placeId
|
|
1591
|
+
&& participantIds.some(id => conversation.participantIds.includes(id)));
|
|
1592
|
+
if (existing) {
|
|
1593
|
+
for (const id of participantIds)
|
|
1594
|
+
if (!existing.participantIds.includes(id))
|
|
1595
|
+
existing.participantIds.push(id);
|
|
1596
|
+
return existing;
|
|
1597
|
+
}
|
|
1598
|
+
let id = `conversation-${state.conversations.length + 1}`;
|
|
1599
|
+
if (state.engine >= STABLE_CONVERSATION_ENGINE) {
|
|
1600
|
+
let ordinal = 0;
|
|
1601
|
+
do {
|
|
1602
|
+
id = `conversation-${seq}-${++ordinal}`;
|
|
1603
|
+
} while (state.conversations.some(conversation => conversation.id === id));
|
|
1604
|
+
}
|
|
1605
|
+
const conversation = { id, placeId, participantIds: [...new Set(participantIds)], startedAt: at, endedAt: null, lines: [] };
|
|
1606
|
+
state.conversations.push(conversation);
|
|
1607
|
+
return conversation;
|
|
1608
|
+
}
|
|
1609
|
+
// A new line goes below the words already written; when the page is full, the first words stay and
|
|
1610
|
+
// the newest line is kept.
|
|
1611
|
+
function addWriting(written, line) {
|
|
1612
|
+
const joined = `${written}\n${line}`;
|
|
1613
|
+
if (joined.length <= MAX_TEXT)
|
|
1614
|
+
return joined;
|
|
1615
|
+
const tail = line.slice(0, MAX_TEXT - 2);
|
|
1616
|
+
return `${written.slice(0, MAX_TEXT - tail.length - 2)}…\n${tail}`;
|
|
1617
|
+
}
|
|
1618
|
+
// Lines to someone who does nothing others can see in return pile up. Anything they are seen or heard
|
|
1619
|
+
// to do ends the count for everyone waiting on them, and a line on a new day starts it over.
|
|
1620
|
+
const QUIET_KINDS = new Set(['plan', 'tick', 'clock', 'opening', 'keep-playing', 'wait']);
|
|
1621
|
+
function noteUnanswered(state, event) {
|
|
1622
|
+
const actorId = event.actorId;
|
|
1623
|
+
if (actorId !== null && !QUIET_KINDS.has(event.kind) && (event.perceivedBy === undefined || event.perceivedBy.length > 0)) {
|
|
1624
|
+
for (const person of Object.values(state.people)) {
|
|
1625
|
+
if (person.unanswered?.[actorId] === undefined)
|
|
1626
|
+
continue;
|
|
1627
|
+
delete person.unanswered[actorId];
|
|
1628
|
+
if (Object.keys(person.unanswered).length === 0)
|
|
1629
|
+
delete person.unanswered;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
for (const effect of event.effects) {
|
|
1633
|
+
const line = effect.kind === 'say' ? { speakerId: effect.personId, toIds: effect.toIds }
|
|
1634
|
+
: effect.kind === 'message' || effect.kind === 'dispatch' ? { speakerId: effect.fromId, toIds: effect.toIds } : null;
|
|
1635
|
+
const speaker = line === null ? undefined : state.people[line.speakerId];
|
|
1636
|
+
if (line === null || speaker === undefined || speaker.isPlayer)
|
|
1637
|
+
continue;
|
|
1638
|
+
for (const listenerId of line.toIds) {
|
|
1639
|
+
const entry = speaker.unanswered?.[listenerId];
|
|
1640
|
+
const fresh = entry === undefined || event.at - entry.firstAt >= UNANSWERED_RESET_MINUTES;
|
|
1641
|
+
speaker.unanswered = { ...speaker.unanswered, [listenerId]: fresh ? { lines: 1, firstAt: event.at } : { lines: entry.lines + 1, firstAt: entry.firstAt } };
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
// A paper is listed under the person who wrote it, and leaves the list of anyone who no longer is its author.
|
|
1646
|
+
function noteAuthor(state, entityId, previous, next) {
|
|
1647
|
+
const authored = { ...state.authoredIds };
|
|
1648
|
+
if (previous !== null && authored[previous])
|
|
1649
|
+
authored[previous] = authored[previous].filter(id => id !== entityId);
|
|
1650
|
+
if (next !== null && state.people[next])
|
|
1651
|
+
authored[next] = [...(authored[next] ?? []).filter(id => id !== entityId), entityId];
|
|
1652
|
+
state.authoredIds = authored;
|
|
1653
|
+
}
|
|
1654
|
+
// A vote's count so far, as anyone who sees it can read it.
|
|
1655
|
+
function tally(decision) {
|
|
1656
|
+
const cast = Object.values(decision.ballots ?? {});
|
|
1657
|
+
const count = (choice) => cast.filter(value => value === choice).length;
|
|
1658
|
+
return `yes ${count('yes')}, no ${count('no')}${count('abstain') > 0 ? `, abstaining ${count('abstain')}` : ''} of ${decision.members.length}`;
|
|
1659
|
+
}
|
|
1660
|
+
// The votes needed to pass: the number the decision names, or more than half its members.
|
|
1661
|
+
function neededVotes(decision) {
|
|
1662
|
+
const needed = decision.properties.needed?.value;
|
|
1663
|
+
return typeof needed === 'number' && needed > 0 ? needed : Math.floor(decision.members.length / 2) + 1;
|
|
1664
|
+
}
|
|
1665
|
+
export function countResult(decision) {
|
|
1666
|
+
const cast = Object.values(decision.ballots ?? {});
|
|
1667
|
+
if (cast.filter(value => value === 'yes').length >= neededVotes(decision))
|
|
1668
|
+
return 'passed';
|
|
1669
|
+
return cast.length === 0 ? 'lapsed' : 'failed';
|
|
1670
|
+
}
|
|
1671
|
+
// Why no vote can be cast on a decision now, or null while it is open.
|
|
1672
|
+
export function votingShut(state, decision, at) {
|
|
1673
|
+
if (decision.result !== undefined)
|
|
1674
|
+
return `was decided: ${decision.result}`;
|
|
1675
|
+
const opensAt = decision.properties['opens-at']?.value;
|
|
1676
|
+
if (typeof opensAt === 'number' && at < opensAt)
|
|
1677
|
+
return `opens to votes at ${clockLabel(opensAt)}`;
|
|
1678
|
+
const closesAt = decision.properties['closes-at']?.value;
|
|
1679
|
+
if (typeof closesAt === 'number' && at >= closesAt)
|
|
1680
|
+
return `closed to votes at ${clockLabel(closesAt)}`;
|
|
1681
|
+
return null;
|
|
1682
|
+
}
|
|
1683
|
+
// A vote is counted when its last member votes or its time is up; a problem it settles is settled by the result.
|
|
1684
|
+
function closeDecision(state, event, decision) {
|
|
1685
|
+
decision.result = countResult(decision);
|
|
1686
|
+
decision.condition = withSide(decision, 'result', decision.result, state.engine >= WHOLE_SIDES_ENGINE, state.engine >= SIDE_ORDER_ENGINE);
|
|
1687
|
+
state.openDecisionIds = (state.openDecisionIds ?? []).filter(id => id !== decision.id);
|
|
1688
|
+
state.schedule = state.schedule.filter(item => item.count?.entityId !== decision.id);
|
|
1689
|
+
// The money a passed decision moves; a payment that cannot be made is left out, as the count still stands.
|
|
1690
|
+
if (decision.result === 'passed')
|
|
1691
|
+
for (const transfer of decision.transfers ?? []) {
|
|
1692
|
+
try {
|
|
1693
|
+
applyEffect(state, event, { kind: 'transfer', ...transfer });
|
|
1694
|
+
}
|
|
1695
|
+
catch (error) {
|
|
1696
|
+
if (!(error instanceof LivingWorldError))
|
|
1697
|
+
throw error;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
const tension = decision.settles === undefined ? undefined : state.tensions.find(candidate => candidate.id === decision.settles);
|
|
1701
|
+
if (tension?.status === 'open')
|
|
1702
|
+
applyEffect(state, event, { kind: 'tension.update', tensionId: tension.id, status: 'settled', note: countNote(decision) });
|
|
1703
|
+
}
|
|
1704
|
+
// What a count writes as the settlement of the problem it settles: the question, the result and the count.
|
|
1705
|
+
export function countNote(decision) {
|
|
1706
|
+
return `${String(decision.properties.question?.value ?? decision.name).slice(0, 300)}: ${decision.result}, ${tally(decision)}.`;
|
|
1707
|
+
}
|
|
1708
|
+
// A vote's written opening or closing time puts its count on the schedule, in place of any earlier one.
|
|
1709
|
+
function setCount(state, event, entityId, opens, at) {
|
|
1710
|
+
state.schedule = state.schedule.filter(item => !(item.count?.entityId === entityId && item.count.opens === opens));
|
|
1711
|
+
if (at === null || opens && at <= event.at)
|
|
1712
|
+
return;
|
|
1713
|
+
const decision = state.entities[entityId];
|
|
1714
|
+
insertScheduled(state, { id: generatedId(state.engine, opens ? 'opens' : 'count', [event.seq, entityId], `${opens ? 'opens' : 'count'}-${event.seq}-${entityId}`.slice(0, 64)), at: Math.max(at, event.at), personId: null, kind: 'count',
|
|
1715
|
+
intent: `${opens ? 'Voting opens on' : 'Votes are counted on'} ${decision.name}`.slice(0, MAX_LINE), placeId: decision.placeId, targetId: null, sourceSeq: event.seq, importance: 5,
|
|
1716
|
+
count: { entityId, opens } });
|
|
1717
|
+
}
|
|
1718
|
+
// A thing's condition keeps a line for each named side; a change without a name is to its state. The
|
|
1719
|
+
// oldest other side gives way when a fifth arrives, and the line shows the state first and names the rest.
|
|
1720
|
+
// From engine 48 the order the sides were first set is kept in a list, which a save keeps; before, it was
|
|
1721
|
+
// the record's own key order, which a save in PostgreSQL does not keep.
|
|
1722
|
+
const MAX_SIDES = 4;
|
|
1723
|
+
const MAX_CONDITION = 200;
|
|
1724
|
+
function withSide(thing, aspect, text, whole, ordered) {
|
|
1725
|
+
const sides = { ...(thing.aspects ?? (thing.condition === null ? {} : { state: thing.condition })) };
|
|
1726
|
+
const name = aspect ?? 'state';
|
|
1727
|
+
const order = (ordered && thing.sideOrder ? thing.sideOrder : Object.keys(sides)).filter(side => sides[side] !== undefined);
|
|
1728
|
+
const others = order.filter(side => side !== 'state');
|
|
1729
|
+
if (name !== 'state' && sides[name] === undefined && others.length >= MAX_SIDES) {
|
|
1730
|
+
delete sides[others[0]];
|
|
1731
|
+
order.splice(order.indexOf(others[0]), 1);
|
|
1732
|
+
}
|
|
1733
|
+
if (!order.includes(name))
|
|
1734
|
+
order.push(name);
|
|
1735
|
+
sides[name] = text;
|
|
1736
|
+
const entries = [...(sides.state === undefined ? [] : [['state', sides.state]]),
|
|
1737
|
+
...order.filter(side => side !== 'state').map((side) => [side, sides[side]])];
|
|
1738
|
+
const line = () => entries.map(([side, value]) => side === 'state' ? value : `${side}: ${value}`).join('; ');
|
|
1739
|
+
// From engine 46 the line keeps whole sides: the oldest named side, never the one just written, gives way.
|
|
1740
|
+
while (whole && line().length > MAX_CONDITION && entries.some(([side]) => side !== 'state' && side !== name)) {
|
|
1741
|
+
entries.splice(entries.findIndex(([side]) => side !== 'state' && side !== name), 1);
|
|
1742
|
+
}
|
|
1743
|
+
thing.aspects = Object.fromEntries(entries);
|
|
1744
|
+
if (ordered)
|
|
1745
|
+
thing.sideOrder = entries.map(([side]) => side);
|
|
1746
|
+
return line().slice(0, MAX_CONDITION);
|
|
1747
|
+
}
|
|
1748
|
+
// When someone has left this speaker's last lines unanswered long enough to stop addressing them,
|
|
1749
|
+
// the time of the first of those lines; otherwise null.
|
|
1750
|
+
export function unansweredSince(state, speakerId, listenerId) {
|
|
1751
|
+
const entry = state.people[speakerId]?.unanswered?.[listenerId];
|
|
1752
|
+
if (entry === undefined || entry.lines < UNANSWERED_LINES)
|
|
1753
|
+
return null;
|
|
1754
|
+
const waited = state.clock - entry.firstAt;
|
|
1755
|
+
return waited >= UNANSWERED_MINUTES && waited < UNANSWERED_RESET_MINUTES ? entry.firstAt : null;
|
|
1756
|
+
}
|
|
1757
|
+
function requirePerson(state, personId) {
|
|
1758
|
+
const person = state.people[personId];
|
|
1759
|
+
if (!person)
|
|
1760
|
+
throw new LivingWorldError('effect-invalid', `Unknown person ${personId}`);
|
|
1761
|
+
return person;
|
|
1762
|
+
}
|
|
1763
|
+
// A person's purse or an account, read and set the same way.
|
|
1764
|
+
function purse(state, id) {
|
|
1765
|
+
const person = state.people[id];
|
|
1766
|
+
if (person) {
|
|
1767
|
+
if (person.money === null)
|
|
1768
|
+
throw new LivingWorldError('effect-invalid', `${person.name} keeps no money in this world`);
|
|
1769
|
+
return { name: person.name, person, account: null, allowsDebt: false, balance: () => person.money, set: (value) => { person.money = value; } };
|
|
1770
|
+
}
|
|
1771
|
+
const account = state.accounts?.[id];
|
|
1772
|
+
if (!account)
|
|
1773
|
+
throw new LivingWorldError('effect-invalid', `Unknown person or account ${id}`);
|
|
1774
|
+
return { name: account.name, person: null, account, allowsDebt: account.allowsDebt, balance: () => account.balance, set: (value) => { account.balance = value; } };
|
|
1775
|
+
}
|
|
1776
|
+
function requireKnown(state, id) {
|
|
1777
|
+
if (state.people[id] || readEntity(state, id) || state.objects[id] || state.places[id] || state.tensions.some(tension => tension.id === id))
|
|
1778
|
+
return;
|
|
1779
|
+
throw new LivingWorldError('effect-invalid', `Unknown person, thing, place or problem ${id}`);
|
|
1780
|
+
}
|
|
1781
|
+
function requirePlace(state, placeId) {
|
|
1782
|
+
const place = state.places[placeId];
|
|
1783
|
+
if (!place)
|
|
1784
|
+
throw new LivingWorldError('effect-invalid', `Unknown place ${placeId}`);
|
|
1785
|
+
return place;
|
|
1786
|
+
}
|
|
1787
|
+
function clamp(value) {
|
|
1788
|
+
return Math.max(-100, Math.min(100, value));
|
|
1789
|
+
}
|
|
1790
|
+
// The game time the server clock has reached at a real time. A world without a rate moves only
|
|
1791
|
+
// with its events, so its clock is where the last event left it.
|
|
1792
|
+
// How far past the game time a world has reached a player is shown the clock running, in game
|
|
1793
|
+
// minutes: a few real seconds at its speed.
|
|
1794
|
+
export function livingClockLead(rate) {
|
|
1795
|
+
return 15 + Math.ceil(rate / 12);
|
|
1796
|
+
}
|
|
1797
|
+
export function clockTargetAt(state, realMs) {
|
|
1798
|
+
if (state.clockRate === null || state.clockAnchor === null)
|
|
1799
|
+
return state.clock;
|
|
1800
|
+
if (state.paused)
|
|
1801
|
+
return Math.max(state.clock, state.clockAnchor.game);
|
|
1802
|
+
const passed = Math.max(0, realMs - state.clockAnchor.real);
|
|
1803
|
+
return Math.max(state.clock, state.clockAnchor.game + Math.floor(passed * state.clockRate / 60_000));
|
|
1804
|
+
}
|
|
1805
|
+
// Tick and clock events belong to the world, not to anyone in it, and carry the real time.
|
|
1806
|
+
function checkClockEvent(previous, event) {
|
|
1807
|
+
const clockEffects = event.effects.filter(effect => effect.kind === 'clock');
|
|
1808
|
+
if (event.kind !== 'clock' && clockEffects.length > 0)
|
|
1809
|
+
throw new LivingWorldError('effect-invalid', 'Only a clock event sets the clock');
|
|
1810
|
+
if (event.kind !== 'tick' && event.kind !== 'clock')
|
|
1811
|
+
return;
|
|
1812
|
+
if (event.actorId !== null || event.placeId !== null || event.realAt === undefined) {
|
|
1813
|
+
throw new LivingWorldError('effect-invalid', `A ${event.kind} event belongs to the world and needs a real time`);
|
|
1814
|
+
}
|
|
1815
|
+
if (event.kind === 'clock' && (clockEffects.length !== 1 || event.effects.length !== 1 || event.minutes !== 0)) {
|
|
1816
|
+
throw new LivingWorldError('effect-invalid', 'A clock event sets the clock and nothing else');
|
|
1817
|
+
}
|
|
1818
|
+
if (event.kind === 'tick' && (previous.clockRate === null || event.effects.length > 0 || event.minutes < 1)) {
|
|
1819
|
+
throw new LivingWorldError('effect-invalid', 'A tick moves the clock of a live world and nothing else');
|
|
1820
|
+
}
|
|
1821
|
+
if (event.kind === 'tick' && event.at + event.minutes > clockTargetAt(previous, event.realAt)) {
|
|
1822
|
+
throw new LivingWorldError('effect-invalid', 'A tick cannot pass the server clock');
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
// Travel follows the place graph. Adjacent places are a short walk apart.
|
|
1826
|
+
export const TRAVEL_MINUTES_PER_HOP = 3;
|
|
1827
|
+
export function travelPath(state, from, to) {
|
|
1828
|
+
if (!state.places[from] || !state.places[to])
|
|
1829
|
+
return null;
|
|
1830
|
+
if (from === to)
|
|
1831
|
+
return [from];
|
|
1832
|
+
const previous = new Map([[from, null]]);
|
|
1833
|
+
const queue = [from];
|
|
1834
|
+
while (queue.length > 0) {
|
|
1835
|
+
const current = queue.shift();
|
|
1836
|
+
for (const next of [...state.places[current].connections].sort()) {
|
|
1837
|
+
if (previous.has(next))
|
|
1838
|
+
continue;
|
|
1839
|
+
previous.set(next, current);
|
|
1840
|
+
if (next === to) {
|
|
1841
|
+
const path = [to];
|
|
1842
|
+
let step = current;
|
|
1843
|
+
while (step !== null) {
|
|
1844
|
+
path.unshift(step);
|
|
1845
|
+
step = previous.get(step) ?? null;
|
|
1846
|
+
}
|
|
1847
|
+
return path;
|
|
1848
|
+
}
|
|
1849
|
+
queue.push(next);
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
return null;
|
|
1853
|
+
}
|