From 15ce1cab0f97b2b8cd1c5921028e40b89d1bd784 Mon Sep 17 00:00:00 2001 From: Shiroha7z Date: Fri, 2 Oct 2026 14:55:12 +0800 Subject: [PATCH 1/4] style: enhance global typography and UI elements - Updated brand colors for better visual consistency. - Improved code block and inline code styles for better readability. - Enhanced Markdown table styles for improved aesthetics. - Adjusted path card and module catalog styles for better spacing and alignment. - Refined troubleshoot wizard component for improved usability and visual feedback. --- .changeset/disable-manifest-toggle-policy.md | 7 + .changeset/module-registry-submit.md | 9 + .changeset/platform-runtime-metrics.md | 8 + .changeset/sfmc-desktop-management.md | 8 + .cursor/rules/sfmc-comment-handoff.mdc | 41 - .cursor/rules/sfmc-module-architecture.mdc | 34 - .cursor/rules/sfmc-role-and-style.mdc | 28 - .cursor/rules/sfmc-source-of-truth.mdc | 26 - .cursor/rules/sfmc-standing-contracts.mdc | 46 - .github/workflows/desktop-release.yml | 99 + .github/workflows/module-index-submit.yml | 91 + .github/workflows/module-publish.yml | 14 + .pr-body-0.2.3.md | 16 - .release-0.2.2-notes.md | 18 - AGENTS.md | 14 - CONTRIBUTING.md | 2 - README.md | 32 +- .../engine/generated/export-names-ui.json | 36 - .../engine/generated/export-names.json | 569 -- .../testing/engine/generated/l0-meta.ts | 17 - .../engine/generated/playground-meta.ts | 6557 ----------------- .../testing/engine/generated/server-l0.ts | 1656 ----- .../testing/engine/generated/server-ui-l0.ts | 61 - _sandbox_archive/testing/engine/l2-skip.json | 19 - .../testing/engine/mc-bridge-admin.ts | 85 - .../testing/engine/mc-bridge-diagnostics.ts | 14 - .../testing/engine/mc-bridge-net.ts | 40 - .../testing/engine/mc-bridge-server.ts | 85 - .../testing/engine/mc-bridge-ui.ts | 67 - .../testing/engine/overrides/command.ts | 259 - .../testing/engine/overrides/damage-source.ts | 70 - .../testing/engine/overrides/dimension.ts | 463 -- .../engine/overrides/dynamic-property.ts | 73 - .../testing/engine/overrides/effect.ts | 97 - .../testing/engine/overrides/entity.ts | 343 - .../testing/engine/overrides/events.ts | 109 - .../testing/engine/overrides/exports.json | 34 - .../testing/engine/overrides/health.ts | 96 - .../testing/engine/overrides/inventory.ts | 240 - .../testing/engine/overrides/player.ts | 352 - .../testing/engine/overrides/scoreboard.ts | 237 - .../engine/overrides/screen-display.ts | 58 - .../testing/engine/overrides/system.ts | 124 - .../testing/engine/overrides/ui-host.ts | 53 - .../testing/engine/overrides/ui.ts | 320 - .../testing/engine/overrides/world.ts | 296 - _sandbox_archive/testing/engine/runtime.ts | 109 - .../testing/engine/unimplemented-error.ts | 27 - _sandbox_archive/testing/events-drive.ts | 128 - _sandbox_archive/testing/fake-db.ts | 57 - _sandbox_archive/testing/fixture.ts | 254 - .../testing/host/memory-data-adapter.ts | 75 - _sandbox_archive/testing/index.ts | 74 - _sandbox_archive/testing/lifecycle.ts | 53 - _sandbox_archive/testing/load-module.ts | 55 - _sandbox_archive/testing/minecraft-loader.mjs | 93 - _sandbox_archive/testing/module-log-bridge.ts | 102 - _sandbox_archive/testing/objects.ts | 332 - _sandbox_archive/testing/playground-host.ts | 702 -- _sandbox_archive/testing/sandbox.ts | 589 -- .../bds-tools/host-platform.test.mjs | 55 - _test_archive/bds-tools/is-main.test.mjs | 79 - .../bds-tools/server-properties.test.mjs | 29 - _test_archive/cli/argv-parse.test.mjs | 37 - _test_archive/cli/command-palette.test.mjs | 178 - _test_archive/cli/directory-picker.test.mjs | 79 - _test_archive/cli/llbot-launch.test.mjs | 40 - _test_archive/cli/repl-windows.test.mjs | 155 - _test_archive/cli/terminal-progress.test.mjs | 200 - _test_archive/cli/wrap-log-line.test.mjs | 156 - _test_archive/sdk/conformance.test.mjs | 89 - _test_archive/sdk/emit-callbacks.test.mjs | 121 - _test_archive/sdk/export-coverage.test.mjs | 48 - _test_archive/sdk/module-log-bridge.test.mjs | 33 - _test_archive/sdk/testing-fixture.test.mjs | 105 - _test_archive/sdk/testing-objects.test.mjs | 145 - _test_archive/sdk/testing.test.mjs | 1295 ---- docs/en/index.mdx | 28 +- ...6-09-02-module-loader-cold-boot-cleanup.md | 330 - .../2026-09-09-client-resource-pack-repair.md | 91 - .../plans/2026-09-09-module-config-seeding.md | 209 - ...6-09-09-native-module-command-migration.md | 78 - .../plans/2026-09-12-gui-sdk-absorption.md | 105 - docs/superpowers/prompts/README.md | 27 - docs/superpowers/prompts/self-check.md | 26 - docs/superpowers/prompts/wave-a.md | 75 - docs/superpowers/prompts/wave-b.md | 90 - docs/superpowers/prompts/wave-c.md | 124 - ...2026-09-09-module-config-seeding-design.md | 145 - docs/superpowers/specs/modules/AGENT_BRIEF.md | 110 - docs/superpowers/specs/modules/README.md | 113 - docs/superpowers/specs/modules/_TEMPLATE.md | 87 - .../specs/modules/activity-log-design.md | 146 - docs/superpowers/specs/modules/afk-design.md | 77 - docs/superpowers/specs/modules/area-design.md | 134 - docs/superpowers/specs/modules/chat-design.md | 113 - .../specs/modules/chat-sounds-design.md | 82 - .../superpowers/specs/modules/clean-design.md | 108 - docs/superpowers/specs/modules/coop-design.md | 95 - .../specs/modules/daily-task-design.md | 41 - .../specs/modules/data-backup-design.md | 116 - .../specs/modules/economy-design.md | 197 - .../specs/modules/fly-area-design.md | 96 - .../specs/modules/gamemode-area-design.md | 117 - docs/superpowers/specs/modules/gui-design.md | 321 - .../modules/inventory-switcher-design.md | 98 - docs/superpowers/specs/modules/land-design.md | 217 - .../specs/modules/monitor-design.md | 108 - .../specs/modules/online-time-design.md | 163 - .../specs/modules/peace-area-design.md | 87 - docs/superpowers/specs/modules/qa-design.md | 99 - .../specs/modules/spawn-protect-design.md | 62 - docs/superpowers/specs/ui-studio-design.md | 298 - docs/zh/api/index.mdx | 6 +- docs/zh/api/messages.md | 2 +- docs/zh/api/module-control.md | 6 +- docs/zh/api/monitoring.md | 39 + docs/zh/api/overview.md | 18 +- docs/zh/api/sdk/index.md | 2 +- docs/zh/dev/architecture.md | 129 +- docs/zh/dev/build-pipeline.md | 69 +- docs/zh/dev/cheatsheet.mdx | 86 +- docs/zh/dev/conventions.md | 118 +- docs/zh/dev/index.mdx | 96 +- docs/zh/dev/manifest.md | 92 +- docs/zh/dev/module-author.mdx | 150 +- docs/zh/dev/platform.md | 104 +- docs/zh/dev/publish.md | 118 +- docs/zh/guide/addons.md | 68 +- docs/zh/guide/backup-upgrade.mdx | 22 +- docs/zh/guide/commands.md | 80 +- docs/zh/guide/config.md | 30 +- docs/zh/guide/index.mdx | 118 +- docs/zh/guide/modules.mdx | 151 +- docs/zh/guide/qq-bridge.mdx | 38 +- docs/zh/guide/services.mdx | 157 +- docs/zh/guide/troubleshooting.mdx | 14 +- docs/zh/index.mdx | 38 +- modules/catalog.json | 5 - .../schemas/sapi-manifest.v2.schema.json | 10 +- modules/sdk/@sfmc-sdk/src/contracts/index.ts | 1 + .../src/contracts/platform-capabilities.ts | 7 + .../@sfmc-sdk/src/module-loader/install.ts | 27 +- .../src/module-loader/manifest-schema.ts | 4 - .../@sfmc-sdk/src/module-loader/manifest.ts | 19 +- .../@sfmc-sdk/src/module-loader/runtime.ts | 2 + .../sdk/@sfmc-sdk/src/sapi/runtime/index.ts | 1 + .../@sfmc-sdk/src/sapi/runtime/monitoring.ts | 120 + .../src/sapi/runtime/tick-sampler.ts | 24 + package.json | 3 + packages/bds-tools/src/check-update.ts | 2 +- packages/bds-tools/src/process-probe.ts | 21 +- packages/cli/package.json | 6 +- .../module-install/.sfmc-registry-cache.json | 95 + .../scripts/module-install/fetch-module.mjs | 6 +- .../scripts/module-install/lib/packages.mjs | 9 +- packages/cli/src/command-surface.ts | 16 +- packages/cli/src/daemon/client.ts | 36 +- packages/cli/src/daemon/protocol.ts | 10 +- packages/cli/src/daemon/server.ts | 54 +- packages/cli/src/i18n/locales/en.ts | 1 + packages/cli/src/i18n/locales/zh-CN.ts | 4 +- packages/cli/src/logs.ts | 12 +- packages/cli/src/main.ts | 216 +- packages/cli/src/management/config.ts | 76 + packages/cli/src/management/deployment.ts | 27 + packages/cli/src/management/domain.ts | 105 + packages/cli/src/management/maintenance.ts | 189 + .../cli/src/management/platform-worker.ts | 129 + packages/cli/src/management/server.ts | 193 + packages/cli/src/management/startup.ts | 28 + packages/cli/src/management/stdio.ts | 74 + packages/cli/src/management/tasks.ts | 73 + packages/cli/src/module-commands.ts | 75 +- packages/cli/src/pack-lifecycle.ts | 2 + packages/cli/src/runtime-main.ts | 203 + packages/cli/src/runtime.ts | 2 +- packages/cli/src/services.ts | 12 +- packages/cli/src/world-packs.ts | 7 + .../create-module/templates/base/README.md | 19 +- .../create-module/templates/base/package.json | 1 + .../templates/base/sfmc.registry.json | 5 + packages/db-server/src/domain/metrics.ts | 93 + .../db-server/src/domain/process-resources.ts | 47 + packages/db-server/src/index.ts | 15 +- packages/db-server/src/manifest-loader.ts | 20 +- packages/db-server/src/routes/status.ts | 44 +- packages/db-server/src/service-registry.ts | 4 +- packages/desktop/.gitignore | 5 + packages/desktop/electron-builder.yml | 38 + packages/desktop/index.html | 1 + packages/desktop/package.json | 45 + packages/desktop/runtime-manifest.json | 8 + packages/desktop/scripts/build.mjs | 6 + packages/desktop/scripts/dev.mjs | 9 + packages/desktop/scripts/installer.nsh | 8 + packages/desktop/scripts/package.mjs | 124 + packages/desktop/src/main/hosts.ts | 17 + packages/desktop/src/main/main.ts | 133 + packages/desktop/src/main/preload.ts | 20 + packages/desktop/src/main/profiles.ts | 44 + packages/desktop/src/main/runtime.ts | 52 + packages/desktop/src/main/sessions.ts | 144 + packages/desktop/src/renderer/app/App.tsx | 199 + packages/desktop/src/renderer/app/desktop.tsx | 559 ++ packages/desktop/src/renderer/app/nav.ts | 38 + packages/desktop/src/renderer/app/theme.tsx | 307 + .../renderer/components/CommandPalette.tsx | 181 + .../desktop/src/renderer/components/Gates.tsx | 75 + .../src/renderer/components/MetricChart.tsx | 136 + .../src/renderer/components/MetricsPanel.tsx | 179 + .../renderer/components/OperationSteps.tsx | 58 + .../src/renderer/components/RightRail.tsx | 58 + .../src/renderer/components/Sidebar.tsx | 121 + .../src/renderer/components/TitleBar.tsx | 160 + .../src/renderer/components/controls.tsx | 300 + .../src/renderer/components/feedback.tsx | 245 + .../desktop/src/renderer/components/feeds.tsx | 113 + .../desktop/src/renderer/components/icons.tsx | 216 + .../src/renderer/components/overlays.tsx | 141 + .../src/renderer/components/tooltip.tsx | 32 + .../desktop/src/renderer/components/ui.tsx | 183 + .../desktop/src/renderer/dev/mock-bridge.ts | 396 + .../src/renderer/dialogs/AboutDialog.tsx | 139 + .../src/renderer/dialogs/FlowDialogs.tsx | 228 + .../src/renderer/dialogs/ProfileDialog.tsx | 205 + .../src/renderer/dialogs/StartupDrawer.tsx | 77 + packages/desktop/src/renderer/lib/alerts.ts | 24 + .../desktop/src/renderer/lib/config-form.ts | 135 + packages/desktop/src/renderer/lib/cx.ts | 9 + packages/desktop/src/renderer/lib/format.ts | 132 + .../src/renderer/lib/metric-segments.ts | 25 + packages/desktop/src/renderer/main.tsx | 34 + .../desktop/src/renderer/pages/ConfigPage.tsx | 283 + .../src/renderer/pages/ConsolePage.tsx | 249 + .../src/renderer/pages/ModulesPage.tsx | 187 + .../src/renderer/pages/OverviewPage.tsx | 311 + .../desktop/src/renderer/pages/PacksPage.tsx | 75 + .../src/renderer/pages/PlayersPage.tsx | 330 + .../desktop/src/renderer/pages/TasksPage.tsx | 142 + .../src/renderer/pages/UpdatesPage.tsx | 240 + packages/desktop/src/renderer/style.css | 208 + packages/desktop/src/renderer/styles/base.css | 357 + .../src/renderer/styles/components.css | 875 +++ .../desktop/src/renderer/styles/controls.css | 823 +++ .../desktop/src/renderer/styles/overlays.css | 667 ++ .../desktop/src/renderer/styles/pages.css | 1026 +++ .../desktop/src/renderer/styles/shell.css | 781 ++ packages/desktop/src/shared/api.ts | 38 + packages/desktop/tsconfig.json | 8 + packages/devkit/README.md | 32 +- packages/devkit/build.mjs | 6 +- packages/devkit/package.json | 12 +- .../schemas/registry-author.schema.json | 19 + packages/devkit/src/index.ts | 31 +- packages/devkit/src/registry-cli.ts | 9 + packages/devkit/src/registry-command.ts | 33 + packages/devkit/src/registry-submit.ts | 455 ++ packages/management/package.json | 58 + packages/management/src/index.ts | 9 + packages/management/src/node/client.ts | 59 + packages/management/src/node/index.ts | 3 + .../management/src/node/maintenance-lock.ts | 38 + packages/management/src/protocol/errors.ts | 56 + packages/management/src/protocol/frames.ts | 81 + packages/management/src/protocol/handshake.ts | 46 + packages/management/src/protocol/logs.ts | 38 + packages/management/src/protocol/methods.ts | 60 + packages/management/src/protocol/metrics.ts | 30 + .../management/src/protocol/operations.ts | 75 + packages/management/src/protocol/services.ts | 55 + packages/management/src/protocol/version.ts | 8 + packages/management/tsconfig.json | 33 + packages/meta/bin/sfmc.mjs | 3 + packages/sfmc-extension/build.mjs | 9 +- packages/sfmc-extension/package.json | 10 + packages/sfmc-extension/src/extension.ts | 24 +- .../sfmc-extension/src/panels/commands.ts | 78 +- pnpm-lock.yaml | 2834 ++++++- website/styles/index.css | 209 +- 280 files changed, 19180 insertions(+), 23927 deletions(-) create mode 100644 .changeset/disable-manifest-toggle-policy.md create mode 100644 .changeset/module-registry-submit.md create mode 100644 .changeset/platform-runtime-metrics.md create mode 100644 .changeset/sfmc-desktop-management.md delete mode 100644 .cursor/rules/sfmc-comment-handoff.mdc delete mode 100644 .cursor/rules/sfmc-module-architecture.mdc delete mode 100644 .cursor/rules/sfmc-role-and-style.mdc delete mode 100644 .cursor/rules/sfmc-source-of-truth.mdc delete mode 100644 .cursor/rules/sfmc-standing-contracts.mdc create mode 100644 .github/workflows/desktop-release.yml create mode 100644 .github/workflows/module-index-submit.yml delete mode 100644 .pr-body-0.2.3.md delete mode 100644 .release-0.2.2-notes.md delete mode 100644 _sandbox_archive/testing/engine/generated/export-names-ui.json delete mode 100644 _sandbox_archive/testing/engine/generated/export-names.json delete mode 100644 _sandbox_archive/testing/engine/generated/l0-meta.ts delete mode 100644 _sandbox_archive/testing/engine/generated/playground-meta.ts delete mode 100644 _sandbox_archive/testing/engine/generated/server-l0.ts delete mode 100644 _sandbox_archive/testing/engine/generated/server-ui-l0.ts delete mode 100644 _sandbox_archive/testing/engine/l2-skip.json delete mode 100644 _sandbox_archive/testing/engine/mc-bridge-admin.ts delete mode 100644 _sandbox_archive/testing/engine/mc-bridge-diagnostics.ts delete mode 100644 _sandbox_archive/testing/engine/mc-bridge-net.ts delete mode 100644 _sandbox_archive/testing/engine/mc-bridge-server.ts delete mode 100644 _sandbox_archive/testing/engine/mc-bridge-ui.ts delete mode 100644 _sandbox_archive/testing/engine/overrides/command.ts delete mode 100644 _sandbox_archive/testing/engine/overrides/damage-source.ts delete mode 100644 _sandbox_archive/testing/engine/overrides/dimension.ts delete mode 100644 _sandbox_archive/testing/engine/overrides/dynamic-property.ts delete mode 100644 _sandbox_archive/testing/engine/overrides/effect.ts delete mode 100644 _sandbox_archive/testing/engine/overrides/entity.ts delete mode 100644 _sandbox_archive/testing/engine/overrides/events.ts delete mode 100644 _sandbox_archive/testing/engine/overrides/exports.json delete mode 100644 _sandbox_archive/testing/engine/overrides/health.ts delete mode 100644 _sandbox_archive/testing/engine/overrides/inventory.ts delete mode 100644 _sandbox_archive/testing/engine/overrides/player.ts delete mode 100644 _sandbox_archive/testing/engine/overrides/scoreboard.ts delete mode 100644 _sandbox_archive/testing/engine/overrides/screen-display.ts delete mode 100644 _sandbox_archive/testing/engine/overrides/system.ts delete mode 100644 _sandbox_archive/testing/engine/overrides/ui-host.ts delete mode 100644 _sandbox_archive/testing/engine/overrides/ui.ts delete mode 100644 _sandbox_archive/testing/engine/overrides/world.ts delete mode 100644 _sandbox_archive/testing/engine/runtime.ts delete mode 100644 _sandbox_archive/testing/engine/unimplemented-error.ts delete mode 100644 _sandbox_archive/testing/events-drive.ts delete mode 100644 _sandbox_archive/testing/fake-db.ts delete mode 100644 _sandbox_archive/testing/fixture.ts delete mode 100644 _sandbox_archive/testing/host/memory-data-adapter.ts delete mode 100644 _sandbox_archive/testing/index.ts delete mode 100644 _sandbox_archive/testing/lifecycle.ts delete mode 100644 _sandbox_archive/testing/load-module.ts delete mode 100644 _sandbox_archive/testing/minecraft-loader.mjs delete mode 100644 _sandbox_archive/testing/module-log-bridge.ts delete mode 100644 _sandbox_archive/testing/objects.ts delete mode 100644 _sandbox_archive/testing/playground-host.ts delete mode 100644 _sandbox_archive/testing/sandbox.ts delete mode 100644 _test_archive/bds-tools/host-platform.test.mjs delete mode 100644 _test_archive/bds-tools/is-main.test.mjs delete mode 100644 _test_archive/bds-tools/server-properties.test.mjs delete mode 100644 _test_archive/cli/argv-parse.test.mjs delete mode 100644 _test_archive/cli/command-palette.test.mjs delete mode 100644 _test_archive/cli/directory-picker.test.mjs delete mode 100644 _test_archive/cli/llbot-launch.test.mjs delete mode 100644 _test_archive/cli/repl-windows.test.mjs delete mode 100644 _test_archive/cli/terminal-progress.test.mjs delete mode 100644 _test_archive/cli/wrap-log-line.test.mjs delete mode 100644 _test_archive/sdk/conformance.test.mjs delete mode 100644 _test_archive/sdk/emit-callbacks.test.mjs delete mode 100644 _test_archive/sdk/export-coverage.test.mjs delete mode 100644 _test_archive/sdk/module-log-bridge.test.mjs delete mode 100644 _test_archive/sdk/testing-fixture.test.mjs delete mode 100644 _test_archive/sdk/testing-objects.test.mjs delete mode 100644 _test_archive/sdk/testing.test.mjs delete mode 100644 docs/superpowers/plans/2026-09-02-module-loader-cold-boot-cleanup.md delete mode 100644 docs/superpowers/plans/2026-09-09-client-resource-pack-repair.md delete mode 100644 docs/superpowers/plans/2026-09-09-module-config-seeding.md delete mode 100644 docs/superpowers/plans/2026-09-09-native-module-command-migration.md delete mode 100644 docs/superpowers/plans/2026-09-12-gui-sdk-absorption.md delete mode 100644 docs/superpowers/prompts/README.md delete mode 100644 docs/superpowers/prompts/self-check.md delete mode 100644 docs/superpowers/prompts/wave-a.md delete mode 100644 docs/superpowers/prompts/wave-b.md delete mode 100644 docs/superpowers/prompts/wave-c.md delete mode 100644 docs/superpowers/specs/2026-09-09-module-config-seeding-design.md delete mode 100644 docs/superpowers/specs/modules/AGENT_BRIEF.md delete mode 100644 docs/superpowers/specs/modules/README.md delete mode 100644 docs/superpowers/specs/modules/_TEMPLATE.md delete mode 100644 docs/superpowers/specs/modules/activity-log-design.md delete mode 100644 docs/superpowers/specs/modules/afk-design.md delete mode 100644 docs/superpowers/specs/modules/area-design.md delete mode 100644 docs/superpowers/specs/modules/chat-design.md delete mode 100644 docs/superpowers/specs/modules/chat-sounds-design.md delete mode 100644 docs/superpowers/specs/modules/clean-design.md delete mode 100644 docs/superpowers/specs/modules/coop-design.md delete mode 100644 docs/superpowers/specs/modules/daily-task-design.md delete mode 100644 docs/superpowers/specs/modules/data-backup-design.md delete mode 100644 docs/superpowers/specs/modules/economy-design.md delete mode 100644 docs/superpowers/specs/modules/fly-area-design.md delete mode 100644 docs/superpowers/specs/modules/gamemode-area-design.md delete mode 100644 docs/superpowers/specs/modules/gui-design.md delete mode 100644 docs/superpowers/specs/modules/inventory-switcher-design.md delete mode 100644 docs/superpowers/specs/modules/land-design.md delete mode 100644 docs/superpowers/specs/modules/monitor-design.md delete mode 100644 docs/superpowers/specs/modules/online-time-design.md delete mode 100644 docs/superpowers/specs/modules/peace-area-design.md delete mode 100644 docs/superpowers/specs/modules/qa-design.md delete mode 100644 docs/superpowers/specs/modules/spawn-protect-design.md delete mode 100644 docs/superpowers/specs/ui-studio-design.md create mode 100644 docs/zh/api/monitoring.md delete mode 100644 modules/catalog.json create mode 100644 modules/sdk/@sfmc-sdk/src/contracts/platform-capabilities.ts create mode 100644 modules/sdk/@sfmc-sdk/src/sapi/runtime/monitoring.ts create mode 100644 modules/sdk/@sfmc-sdk/src/sapi/runtime/tick-sampler.ts create mode 100644 packages/cli/scripts/module-install/.sfmc-registry-cache.json create mode 100644 packages/cli/src/management/config.ts create mode 100644 packages/cli/src/management/deployment.ts create mode 100644 packages/cli/src/management/domain.ts create mode 100644 packages/cli/src/management/maintenance.ts create mode 100644 packages/cli/src/management/platform-worker.ts create mode 100644 packages/cli/src/management/server.ts create mode 100644 packages/cli/src/management/startup.ts create mode 100644 packages/cli/src/management/stdio.ts create mode 100644 packages/cli/src/management/tasks.ts create mode 100644 packages/cli/src/runtime-main.ts create mode 100644 packages/create-module/templates/base/sfmc.registry.json create mode 100644 packages/db-server/src/domain/metrics.ts create mode 100644 packages/db-server/src/domain/process-resources.ts create mode 100644 packages/desktop/.gitignore create mode 100644 packages/desktop/electron-builder.yml create mode 100644 packages/desktop/index.html create mode 100644 packages/desktop/package.json create mode 100644 packages/desktop/runtime-manifest.json create mode 100644 packages/desktop/scripts/build.mjs create mode 100644 packages/desktop/scripts/dev.mjs create mode 100644 packages/desktop/scripts/installer.nsh create mode 100644 packages/desktop/scripts/package.mjs create mode 100644 packages/desktop/src/main/hosts.ts create mode 100644 packages/desktop/src/main/main.ts create mode 100644 packages/desktop/src/main/preload.ts create mode 100644 packages/desktop/src/main/profiles.ts create mode 100644 packages/desktop/src/main/runtime.ts create mode 100644 packages/desktop/src/main/sessions.ts create mode 100644 packages/desktop/src/renderer/app/App.tsx create mode 100644 packages/desktop/src/renderer/app/desktop.tsx create mode 100644 packages/desktop/src/renderer/app/nav.ts create mode 100644 packages/desktop/src/renderer/app/theme.tsx create mode 100644 packages/desktop/src/renderer/components/CommandPalette.tsx create mode 100644 packages/desktop/src/renderer/components/Gates.tsx create mode 100644 packages/desktop/src/renderer/components/MetricChart.tsx create mode 100644 packages/desktop/src/renderer/components/MetricsPanel.tsx create mode 100644 packages/desktop/src/renderer/components/OperationSteps.tsx create mode 100644 packages/desktop/src/renderer/components/RightRail.tsx create mode 100644 packages/desktop/src/renderer/components/Sidebar.tsx create mode 100644 packages/desktop/src/renderer/components/TitleBar.tsx create mode 100644 packages/desktop/src/renderer/components/controls.tsx create mode 100644 packages/desktop/src/renderer/components/feedback.tsx create mode 100644 packages/desktop/src/renderer/components/feeds.tsx create mode 100644 packages/desktop/src/renderer/components/icons.tsx create mode 100644 packages/desktop/src/renderer/components/overlays.tsx create mode 100644 packages/desktop/src/renderer/components/tooltip.tsx create mode 100644 packages/desktop/src/renderer/components/ui.tsx create mode 100644 packages/desktop/src/renderer/dev/mock-bridge.ts create mode 100644 packages/desktop/src/renderer/dialogs/AboutDialog.tsx create mode 100644 packages/desktop/src/renderer/dialogs/FlowDialogs.tsx create mode 100644 packages/desktop/src/renderer/dialogs/ProfileDialog.tsx create mode 100644 packages/desktop/src/renderer/dialogs/StartupDrawer.tsx create mode 100644 packages/desktop/src/renderer/lib/alerts.ts create mode 100644 packages/desktop/src/renderer/lib/config-form.ts create mode 100644 packages/desktop/src/renderer/lib/cx.ts create mode 100644 packages/desktop/src/renderer/lib/format.ts create mode 100644 packages/desktop/src/renderer/lib/metric-segments.ts create mode 100644 packages/desktop/src/renderer/main.tsx create mode 100644 packages/desktop/src/renderer/pages/ConfigPage.tsx create mode 100644 packages/desktop/src/renderer/pages/ConsolePage.tsx create mode 100644 packages/desktop/src/renderer/pages/ModulesPage.tsx create mode 100644 packages/desktop/src/renderer/pages/OverviewPage.tsx create mode 100644 packages/desktop/src/renderer/pages/PacksPage.tsx create mode 100644 packages/desktop/src/renderer/pages/PlayersPage.tsx create mode 100644 packages/desktop/src/renderer/pages/TasksPage.tsx create mode 100644 packages/desktop/src/renderer/pages/UpdatesPage.tsx create mode 100644 packages/desktop/src/renderer/style.css create mode 100644 packages/desktop/src/renderer/styles/base.css create mode 100644 packages/desktop/src/renderer/styles/components.css create mode 100644 packages/desktop/src/renderer/styles/controls.css create mode 100644 packages/desktop/src/renderer/styles/overlays.css create mode 100644 packages/desktop/src/renderer/styles/pages.css create mode 100644 packages/desktop/src/renderer/styles/shell.css create mode 100644 packages/desktop/src/shared/api.ts create mode 100644 packages/desktop/tsconfig.json create mode 100644 packages/devkit/schemas/registry-author.schema.json create mode 100644 packages/devkit/src/registry-cli.ts create mode 100644 packages/devkit/src/registry-command.ts create mode 100644 packages/devkit/src/registry-submit.ts create mode 100644 packages/management/package.json create mode 100644 packages/management/src/index.ts create mode 100644 packages/management/src/node/client.ts create mode 100644 packages/management/src/node/index.ts create mode 100644 packages/management/src/node/maintenance-lock.ts create mode 100644 packages/management/src/protocol/errors.ts create mode 100644 packages/management/src/protocol/frames.ts create mode 100644 packages/management/src/protocol/handshake.ts create mode 100644 packages/management/src/protocol/logs.ts create mode 100644 packages/management/src/protocol/methods.ts create mode 100644 packages/management/src/protocol/metrics.ts create mode 100644 packages/management/src/protocol/operations.ts create mode 100644 packages/management/src/protocol/services.ts create mode 100644 packages/management/src/protocol/version.ts create mode 100644 packages/management/tsconfig.json diff --git a/.changeset/disable-manifest-toggle-policy.md b/.changeset/disable-manifest-toggle-policy.md new file mode 100644 index 00000000..edfcb7e0 --- /dev/null +++ b/.changeset/disable-manifest-toggle-policy.md @@ -0,0 +1,7 @@ +--- +"@sfmc-bds/sdk": minor +"@sfmc-bds/db-server": minor +"@sfmc-bds/cli": minor +--- + +停用模块 manifest 的 `canDisable` 与 `enabledByDefault`:从 JSON Schema 和 TypeScript 契约移除,SDK v2/v3 校验及 DB 加载器拒绝包含这些字段的声明。模块作者需删除旧字段;安装目录投影统一默认启用、允许服主禁用,已有启停状态仍由 module-lock 管理,升级继续保留。 diff --git a/.changeset/module-registry-submit.md b/.changeset/module-registry-submit.md new file mode 100644 index 00000000..baee4768 --- /dev/null +++ b/.changeset/module-registry-submit.md @@ -0,0 +1,9 @@ +--- +"@sfmc-bds/devkit": major +"@sfmc-bds/cli": minor +"@sfmc-bds/create-module": patch +--- + +增加模块索引预览和一键 PR 提交,共用 npm、Schema、来源与依赖校验,支持重复提交更新及发布后 CI 接入;脚手架提供索引展示配置。 + +为避免 CLI 与 devkit 循环依赖,devkit 不再隐式安装 CLI。使用重建部署功能的独立 devkit 消费者需另行安装 @sfmc-bds/cli,或配置 SFMC_CLI / cliPath;官方扩展继续自带 CLI。 diff --git a/.changeset/platform-runtime-metrics.md b/.changeset/platform-runtime-metrics.md new file mode 100644 index 00000000..cdfbd267 --- /dev/null +++ b/.changeset/platform-runtime-metrics.md @@ -0,0 +1,8 @@ +--- +"@sfmc-bds/sdk": minor +"@sfmc-bds/db-server": minor +"@sfmc-bds/cli": minor +"@sfmc-bds/management": minor +--- + +将 TPS、在线人数、维度实体数量与视距区块估算加入平台宿主的基础运行监控,提供受控指标读取、72 小时有界历史和主机内存、进程资源信息。桌面总览使用实际采样并区分断采、过期与未知状态。 diff --git a/.changeset/sfmc-desktop-management.md b/.changeset/sfmc-desktop-management.md new file mode 100644 index 00000000..84443298 --- /dev/null +++ b/.changeset/sfmc-desktop-management.md @@ -0,0 +1,8 @@ +--- +"@sfmc-bds/management": minor +"@sfmc-bds/cli": minor +"@sfmc-bds/sfmc": patch +"@sfmc-bds/bds-tools": patch +--- + +增加版本化桌面管理协议、非交互 stdio 入口、持久化任务和跨进程维护锁;守护进程可由独立 Node 与显式 CLI 入口启动,保持原 CLI 协议兼容。 diff --git a/.cursor/rules/sfmc-comment-handoff.mdc b/.cursor/rules/sfmc-comment-handoff.mdc deleted file mode 100644 index eed74a59..00000000 --- a/.cursor/rules/sfmc-comment-handoff.mdc +++ /dev/null @@ -1,41 +0,0 @@ ---- -description: >- - 写代码时不落注释;只汇总「该写注释的位置」交给用户转给 Gemini 补写。 - 在当前仓库中始终生效。 -alwaysApply: true ---- - -# 注释交接:代码不写,清单后补 - -写或改源码时:**不要新增任何注释**(含文件头、JSDoc/`///`、行内与块注释)。现有注释不要顺手删。 - -需要说明设计意图时,在本轮回复末尾给出一份 **待写注释清单**,由用户交给 Gemini 落笔。清单用简体中文,只写「写在哪、为什么要写」,不要写成可粘贴的成稿注释。 - -## 该列入清单的位置 - -- 非显而易见的不变量、兼容边界、版本分派、权限/契约入口 -- 容易误读的命名、巧妙/权宜的实现、与文档不一致的现行行为 -- 公开 API / 导出符号(将来补 JSDoc 的位置) - -不要列入:复述代码的句子、与标识符同义的说明。 - -## 清单格式 - -```markdown -## 待写注释 - -### `相对路径` -- `符号或锚点`:一句说明此处要交代的设计意图(非注释成稿) -``` - -示例: - -```markdown -## 待写注释 - -### `modules/sdk/@sfmc-sdk/src/module-loader/manifest.ts` -- `validateManifest`:按 schemaVersion 分派 v2/v3;未知版本走同一套 const_mismatch -- `collectCoreIssues`:v2/v3 共用顶层字段,避免两套文案 -``` - -无此类位置则写 `## 待写注释` + `(无)`。本轮只有文档/规则/配置、零源码改动时,可省略清单。 diff --git a/.cursor/rules/sfmc-module-architecture.mdc b/.cursor/rules/sfmc-module-architecture.mdc deleted file mode 100644 index e4ee8790..00000000 --- a/.cursor/rules/sfmc-module-architecture.mdc +++ /dev/null @@ -1,34 +0,0 @@ ---- -description: >- - 官方第一方业务模块架构铁律:短名一致性、依赖白名单、生命周期、跨模块 service RPC、 - 表命名与防御性编码。与 docs/superpowers/specs/modules/AGENT_BRIEF.md 对齐; - 实现业务模块或阅读 *-design.md 时必须遵守。 -alwaysApply: true ---- - -# SFMC:官方模块架构铁律 - -你是 SFMC 官方第一方核心模块开发专家。行为权威为对应 -`docs/superpowers/specs/modules/-design.md`,共用契约见 -`docs/superpowers/specs/modules/AGENT_BRIEF.md`。禁止自由发挥或移植旧 archive 代码。 - -开仓剧本与分波次提示词:`.cursor/skills/sfmc-module-kickoff` · -`docs/superpowers/prompts/`。 - -## 铁律(与 AGENT_BRIEF 一致) - -1. **短名一致性**:`manifest.id` / install id / npm 后缀均为 kebab-case 短名(如 `land`、`economy`),**不加** `feature-` / `core-`。`configKey` 为下划线(`online_time`)。脚手架写出不一致时立刻改齐。 -2. **依赖白名单** - - 允许:`@sfmc-bds/sdk/module-loader`;`@sfmc-bds/sdk/sapi/runtime`(`Msg` / `Command` / `Permission` / `MenuNavigator` / `Money` / `debug`);`sapi/db` · `sapi/config` · `sapi/service`;`@minecraft/*`;manifest `$schema` 指向 SDK schemas。 - - 禁止:裸 `@sfmc-bds/sdk`、SDK 私有路径、`db-server` / `cli` / `bds-tools` / `module-loader/install` / `sapi/host` / `node/*`;任何 `@sfmc-bds/module-*` 或其它模块源码的 npm/相对路径 import。 -3. **生命周期**:`registerPermissions` → `registerCommands` → `registerEvents` → `init`;清理在 `cleanup`。事件订阅只在 `registerEvents`;表在 `init` 用 `db.defineTable`。世界实装逻辑声明 `afterWorldLoad: true`。 -4. **跨模块纯度**:仅 `service.provide` / `service.call`(及文档约定注册);manifest 显式 `requires` 与 `services.requires`。表命名空间 `sfmc__*`(短名中 `-` 按既有规格落为 `_`);严禁跨模块读写他模块私有表。 -5. **防御性编码**:动态 SQL 用 `sql` 标签;玩家消息用 `Msg.*`(禁止裸 `player.sendMessage`);命令前缀同时支持 `!` 与 `!`。 - -## 开仓闸门(勿幻觉绕过) - -凡 `provides` 含 SAPI 进程内能力的模块,依赖 SDK 已具备进程内 `service.provide` 与本地优先的 `service.call`。能力未合并发布前,不得开始依赖相关 RPC 的并行实现验收。细节以 AGENT_BRIEF「平台前置」为准。 - -## Manifest v2 - -必填:`schemaVersion: 2`、`id`、`name`、`configKey`、`requires`、`permissions`、`services.provides`、`services.requires`。禁止 v1 遗留字段(`routes` / `tables` / `migrations` / `seeds` / `handlers` / `events`)。 diff --git a/.cursor/rules/sfmc-role-and-style.mdc b/.cursor/rules/sfmc-role-and-style.mdc deleted file mode 100644 index a12f7fb5..00000000 --- a/.cursor/rules/sfmc-role-and-style.mdc +++ /dev/null @@ -1,28 +0,0 @@ ---- -description: >- - 项目角色与应答风格规范:资深研发工程师定位、默认简体中文、直接高效、DRY、 - 关注点分离、主动识别坏味道与安全风险、面向用户的正向文档叙述。在当前仓库中始终生效。 -alwaysApply: true ---- - -# 角色与应答风格 - -## 角色定位 - -- 作为资深软件工程师与协同开发伙伴。 -- 沟通交流与方案设计默认使用 **简体中文**。源码注释不在本轮落笔,按 `sfmc-comment-handoff` 汇总位置。 -- 优先遵循业界标准与工程最佳实践,兼顾方案的严谨性与扩展性。 -- 严格遵循 DRY(Don't Repeat Yourself)原则,避免冗余实现。 -- 倡导前后端分离、模块化与高内聚低耦合设计,注重架构设计与代码美感。 - -## 协作与应答 - -1. **高效主动**:直击要点、条理清晰,主动思考边界场景与系统潜在风险,存疑时及时向用户确认。 -2. **代码质量**:提供完整、可直接运行的代码;复杂逻辑的注释位置写入待写清单,不在源码里先写一版。 -3. **重构与安全**:主动识别代码坏味道、边界漏洞与安全隐患;优先采用平台惯用模式与现代语言特性。 - -## 文档与输出格式 - -- **用户正向视角**:描述机制与功能时,直接阐述“现行规则与具体使用方式”,避免使用“不能……”、“不以……”等带有历史演进对比或内部解释性的否定式冗长描述。 -- **文件生成标注**:生成或修改文件时,在代码块顶部或上下文明确标注完整文件路径。 - diff --git a/.cursor/rules/sfmc-source-of-truth.mdc b/.cursor/rules/sfmc-source-of-truth.mdc deleted file mode 100644 index c5586ff4..00000000 --- a/.cursor/rules/sfmc-source-of-truth.mdc +++ /dev/null @@ -1,26 +0,0 @@ ---- -description: >- - SFMC 权威来源(Source of Truth):以精简版 AGENTS.md 和项目 skills 作为现行规范基准; - 历史对话与过往 plan 仅用于背景追溯。在当前仓库中始终生效。 -alwaysApply: true ---- - -# SFMC:权威信息源 - -## 现行规范层级 - -| 层级 | 路径 | 定位与职责 | -|------|------|------------| -| 仓级速查 | `AGENTS.md` | 平台架构全局索引、环境约定与操作速查 | -| 模块契约 | `docs/superpowers/specs/modules/AGENT_BRIEF.md` · `*-design.md` | 官方业务模块行为权威与开仓共用简报 | -| 模块提示词 | `docs/superpowers/prompts/` | Wave A→C 专属开工提示与自检模板索引 | -| 技能剧本 | `.cursor/skills/sfmc-onboarding` · `sfmc-module-author` · `sfmc-module-kickoff` · `sfmc-code-review` | 具体研发场景的操作标准与检查规范 | -| 详细文档 | `docs/zh/dev/` | 深入的技术设计与原理阐述(与源码冲突时以文档与源码综合对齐为准) | - -开发与答复前优先对齐上述权威来源;在对话中提炼关键要点引用即可,避免大篇幅重复粘贴。官方模块开仓时:**设计方案是唯一行为权威**,Prompt Suite 只约束落地方式,不新增契约。 - -## 历史记录与旧规划 - -- **历史对话与转录记录**(历史聊天 / agent-transcripts / SearchConversations):仅在需要追溯特定决策背景或技术演进脉络时检索参考。 -- **现行效力**:历史记录不作为系统当前的技术契约;当历史上下文与 `AGENTS.md` / `skills` / 现行源码冲突时,严格以后者为准。 -- **旧规划文件**:未明确关联当前任务的过往 plan,默认不作为当前执行依据。 diff --git a/.cursor/rules/sfmc-standing-contracts.mdc b/.cursor/rules/sfmc-standing-contracts.mdc deleted file mode 100644 index 02ecc91a..00000000 --- a/.cursor/rules/sfmc-standing-contracts.mdc +++ /dev/null @@ -1,46 +0,0 @@ ---- -description: >- - SFMC 现行架构契约:三根路径定义、作者面与运维面职责划分、既定交互契约、 - 彻底清理废弃逻辑、能力归属边界与 UTF-8 规范。在当前仓库中始终生效。 -alwaysApply: true ---- - -# SFMC:现行架构契约 - -具体操作剧本参见 skills:`sfmc-onboarding` / `sfmc-module-author` / `sfmc-module-kickoff` / `sfmc-code-review`。 - -## 三根路径 - -| 路径标识 | 职责与用途 | 说明 | -|----------|------------|------| -| **monorepo** | 平台核心源码 | 本工程仓库,承载平台包与核心 SDK | -| **SFMC_ROOT** | 运行时工作目录 | 存放 `configs/`、`modules/` 与运行时数据;扩展设置项 `sfmc.root` 指向此处 | -| **作者仓** | 独立业务模块仓库 | 单模块独立 Git 仓库,通过 `mod install --link` 挂载至工作目录进行联调 | - -所有文档与交互文案中统一使用「SFMC **工作目录**」。路径包含 `#` 或空格时,参数必须使用引号包裹。 - -## 作者面与运维面 - -| 关注面 | 核心工具 | 核心职责 | -|--------|----------|----------| -| **运维面** | `sfmc` CLI / REPL(在 SFMC_ROOT 执行) | 模块安装(install)、启用(enable)、打包组装(build)、热重载(reload)与服务启停 | -| **作者面** | `npm create @sfmc-bds/module` + 扩展「SFMC Module」+ `devkit` | 脚手架建仓、单元测试、本地 link 挂载、实时 watch 构建与模块发布 | - -核心能力内聚于 `bds-tools` / `db-server` / `qq-bridge` / SDK 中,CLI 仅负责命令编排与交互桥接。 - -## 已确认的设计契约 - -- **既定语义保持**:用户已确认的命令语义、交互流程与清理决策,在后续重构中严格保持一致;对于边界场景或存在歧义之处,主动向用户确认。 -- **现行逻辑与遗留清理**:基于现行有效逻辑进行增量迭代;对于已确定的废弃分支与旧路径彻底清理,不留失效死代码或兼容残留。 - -## 文案与契约对齐 - -涉及 CLI、扩展或平台文档变更时,必须同步核验命令帮助串、README、`AGENTS.md` 与终端提示,确保路径与指令绝对一致。面向终端用户的文案保持短小精炼、直截了当。 - -## 能力归属与边界 - -修改前严格界定能力归属:**IDE 扩展 / 独立服务包 / 核心 SDK / 业务模块**。清晰区分 SFMC 平台级抽象(如 `ConfigManager`)与 Minecraft 原生 SAPI / BDS 运行机制。 - -## 语言与编码规范 - -沟通与提交说明统一使用 **简体中文**;源码与配置文件强制使用 **UTF-8**。注释由 Gemini 按待写清单补写(见 `sfmc-comment-handoff`),补写后仍用简体中文 UTF-8。 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 00000000..f1b0e9fe --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,99 @@ +name: SFMC Desktop release + +on: + push: + tags: ["desktop-v*"] + workflow_dispatch: + inputs: + signed: + description: "生成签名发行包(需要配置 PFX Secrets 与发布者变量)" + type: boolean + default: true + +permissions: + contents: read + +jobs: + windows: + runs-on: windows-2022 + env: + ELECTRON_CACHE: ${{ github.workspace }}\.cache\electron + ELECTRON_BUILDER_CACHE: ${{ github.workspace }}\.cache\electron-builder + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.8.0 + - uses: actions/setup-node@v4 + with: + node-version: 24.21.0 + cache: pnpm + - run: pnpm install --frozen-lockfile --ignore-scripts + - name: 准备 Electron + run: node packages/desktop/node_modules/electron/install.js + - run: pnpm --filter @sfmc-bds/sfmc... build + - run: pnpm --filter @sfmc-bds/management typecheck + - run: pnpm --filter @sfmc-bds/cli typecheck + - run: pnpm --filter @sfmc-bds/desktop typecheck + - name: 核对桌面标签版本 + if: startsWith(github.ref, 'refs/tags/') + shell: pwsh + run: | + $version = (Get-Content packages/desktop/package.json | ConvertFrom-Json).version + if ($env:GITHUB_REF_NAME -ne "desktop-v$version") { throw "桌面标签与 package.json 版本不一致" } + - name: 打包 + shell: pwsh + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + SFMC_SIGNING_PUBLISHER: ${{ vars.SFMC_SIGNING_PUBLISHER }} + run: | + if ($env:GITHUB_REF -like 'refs/tags/*' -or '${{ inputs.signed }}' -eq 'true') { + pnpm desktop:package --signed + } else { + pnpm desktop:package + } + if ($LASTEXITCODE -ne 0) { throw "桌面打包失败" } + - name: 验证签名身份 + if: startsWith(github.ref, 'refs/tags/') || inputs.signed + shell: pwsh + env: + SFMC_SIGNING_PUBLISHER: ${{ vars.SFMC_SIGNING_PUBLISHER }} + run: | + $files = @(Get-ChildItem packages/desktop/release -Filter '*.exe') + $files += Get-Item 'packages/desktop/release/win-unpacked/SFMC Desktop.exe' + foreach ($file in $files) { + $signature = Get-AuthenticodeSignature -LiteralPath $file.FullName + if ($signature.Status -ne 'Valid') { throw "签名无效:$($file.Name)" } + if ($signature.SignerCertificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) -ne $env:SFMC_SIGNING_PUBLISHER) { throw "签名发布者不匹配" } + } + - uses: actions/upload-artifact@v4 + with: + name: sfmc-desktop-windows-x64 + path: | + packages/desktop/release/*.exe + packages/desktop/release/*.zip + packages/desktop/release/*.yml + packages/desktop/release/*.blockmap + packages/desktop/payload/release-manifest.json + if-no-files-found: error + publish: + if: startsWith(github.ref, 'refs/tags/') + needs: windows + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: sfmc-desktop-windows-x64 + path: artifacts + - name: 发布独立桌面版本 + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + mapfile -t assets < <(find artifacts -type f) + gh release create "$RELEASE_TAG" "${assets[@]}" --title "SFMC Desktop ${RELEASE_TAG#desktop-v}" --generate-notes --latest=false + diff --git a/.github/workflows/module-index-submit.yml b/.github/workflows/module-index-submit.yml new file mode 100644 index 00000000..f286fd2b --- /dev/null +++ b/.github/workflows/module-index-submit.yml @@ -0,0 +1,91 @@ +name: Submit module registry PR + +on: + workflow_call: + inputs: + module_repository: + required: true + type: string + module_ref: + required: true + type: string + module_path: + default: "." + type: string + secrets: + REGISTRY_SUBMIT_TOKEN: + required: true + workflow_dispatch: + inputs: + module_repository: + description: 模块源码仓 owner/repo + required: true + type: string + module_ref: + description: 已发布版本对应的提交 SHA + required: true + type: string + module_path: + description: 模块在仓库中的相对路径 + default: "." + type: string + +permissions: + contents: read + +concurrency: + group: module-index-${{ inputs.module_repository }}-${{ inputs.module_path }} + cancel-in-progress: false + +jobs: + submit: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + repository: DogeLakeDev/ScriptsForMinecraftServer + ref: main + path: platform + persist-credentials: false + - uses: actions/checkout@v4 + with: + repository: ${{ inputs.module_repository }} + ref: ${{ inputs.module_ref }} + path: author + persist-credentials: false + - uses: pnpm/action-setup@v4 + with: + version: 10.8.0 + run_install: false + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: 构建提交工具(不执行模块仓脚本) + working-directory: platform + run: | + pnpm install --frozen-lockfile --filter '@sfmc-bds/devkit...' + node packages/tools/build-publishable.mjs --workspace @sfmc-bds/devkit + - name: 校验模块路径 + env: + MODULE_PATH: ${{ inputs.module_path }} + run: | + node --input-type=module <<'JS' + import fs from 'node:fs'; + import path from 'node:path'; + const root = fs.realpathSync('author'); + const target = fs.realpathSync(path.resolve(root, process.env.MODULE_PATH)); + const relative = path.relative(root, target); + if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('模块路径须位于 author 内'); + if (/[\r\n]/.test(target)) throw new Error('模块路径不能包含换行'); + fs.appendFileSync(process.env.GITHUB_ENV, `SUBMIT_MODULE_ROOT=${target}\n`); + JS + - name: 创建或更新索引 PR + env: + GH_TOKEN: ${{ secrets.REGISTRY_SUBMIT_TOKEN }} + run: | + if [ -z "$GH_TOKEN" ]; then + echo '::error::配置 REGISTRY_SUBMIT_TOKEN(索引仓 Contents / Pull requests 写权限)后重跑索引提交;npm 已发布版本无需重发' + exit 1 + fi + node platform/packages/devkit/dist/registry-cli.js "$SUBMIT_MODULE_ROOT" --no-fork --wait-for-publish diff --git a/.github/workflows/module-publish.yml b/.github/workflows/module-publish.yml index f9560410..a5306bed 100644 --- a/.github/workflows/module-publish.yml +++ b/.github/workflows/module-publish.yml @@ -57,6 +57,8 @@ concurrency: jobs: publish: runs-on: ubuntu-latest + outputs: + npm_published: ${{ steps.npm-publish.outputs.published }} steps: - uses: actions/checkout@v4 with: @@ -124,6 +126,7 @@ jobs: retention-days: 14 - name: 发布到 npm + id: npm-publish if: inputs.dry_run == false env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -133,6 +136,7 @@ jobs: exit 1 fi pnpm publish --access public --tag latest --no-git-checks + echo 'published=true' >> "$GITHUB_OUTPUT" - name: 创建模块版本标签与 GitHub Release if: inputs.dry_run == false && inputs.create_github_release == true @@ -168,3 +172,13 @@ jobs: VERSION: ${{ inputs.version }} COMMIT: ${{ inputs.commit }} run: echo "::notice::npm 发布完成;请用有模块仓写权限的维护者凭据,为 ${MODULE} 的 ${COMMIT} 创建 v${VERSION} 标签与 GitHub Release" + + submit-index: + needs: publish + if: always() && !cancelled() && inputs.dry_run == false && needs.publish.outputs.npm_published == 'true' + uses: ./.github/workflows/module-index-submit.yml + with: + module_repository: DogeLakeDev/sfmc-module-${{ inputs.module }} + module_ref: ${{ inputs.commit }} + secrets: + REGISTRY_SUBMIT_TOKEN: ${{ secrets.REGISTRY_SUBMIT_TOKEN }} diff --git a/.pr-body-0.2.3.md b/.pr-body-0.2.3.md deleted file mode 100644 index d439cc06..00000000 --- a/.pr-body-0.2.3.md +++ /dev/null @@ -1,16 +0,0 @@ -## Summary -- CLI 守护进程:退出 CLI 不停服,经命名管道 RPC 重连 -- 日志历史以 `.sfmc/logs` 落盘回放;实时经 daemon 订阅 -- 移除 `bds_updater` 的 `crash_restart` / `auto_restart`,由 daemon 固定崩溃拉起 -- Windows 子进程 `windowsHide`,避免空白控制台窗口 - -## Versions -- `@sfmc-bds/cli` / `@sfmc-bds/sfmc` → **0.2.3** -- `@sfmc-bds/sdk` / `@sfmc-bds/bds-tools` → **0.2.1**(及依赖链 patch) - -## Test plan -- [ ] CI ootb / typecheck / build 通过 -- [ ] `sfmc start all` 后关终端,服务仍在 -- [ ] 再开 `sfmc`:`status` / 日志 / `send` 正常 -- [ ] `exit` 不停服;`stop all` 停服;`daemon stop` 停守护进程 -- [ ] 本地 `D:\WorkPlace\SFMC` 与 dogelake 生产升级验证 diff --git a/.release-0.2.2-notes.md b/.release-0.2.2-notes.md deleted file mode 100644 index 2569c5fb..00000000 --- a/.release-0.2.2-notes.md +++ /dev/null @@ -1,18 +0,0 @@ -# SFMC 0.2.2 正式版 - -此补丁修正聚合包与 CLI 的随包 README,以及中英文安装文档。运行代码未改动;`@sfmc-bds/cli` 升至 `0.2.2`,`@sfmc-bds/devkit` 因内部依赖升至 `1.0.2`。 - -## 安装 - -需要 Node.js 22.13 或更新版本。使用 pnpm 安装精确版本: - -```bash -pnpm add -g @sfmc-bds/sfmc@0.2.2 -sfmc --version -``` - -pnpm v11 起,[`minimumReleaseAge` 默认是 1440 分钟](https://pnpm.io/settings/dependency-resolution#minimumreleaseage)。新版本发布后的 24 小时内,省略版本号的 `pnpm add -g @sfmc-bds/sfmc` 可能选择更早发布的旧版;这解释了本次用户在新设备上安装到 `0.1.0` 的情况。`pnpm view @sfmc-bds/sfmc version` 可查询 npm `latest`,随后指定该精确版本安装。无需改变全局安全设置。 - -## 运行验收 - -包内文档与安装入口已重新核对,正式服部署和客户端验收由维护者自行执行。升级现有服务器前应按[备份与升级指南](https://github.com/DogeLakeDev/ScriptsForMinecraftServer/blob/main/docs/zh/guide/backup-upgrade.mdx)停服、冷备份并检查链接模块目标;仅压缩 `modules/` 不足以备份其 junction 指向的源码。 diff --git a/AGENTS.md b/AGENTS.md index 396dce26..be9914cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,35 +3,21 @@ ## 基本要求 - 沟通、文档和代码注释使用简体中文;代码标识符沿用项目现有风格。 -- 先查看相关源码和 `package.json`,不要把本文或旧文档当作高于源码的事实。 -- 只修改用户要求范围内的文件,保留工作区中已有的无关改动。 -- 不读取、输出或提交 `configs/`、`data/`、密钥、Token 等运行时敏感内容。 ## 架构边界 - 本仓库是平台 monorepo;运行时工作目录 `SFMC_ROOT` 与单模块作者仓是不同目录。 - 核心能力位于 `packages/*` 与 `modules/sdk/*`;CLI 负责调用和编排,避免复制底层实现。 -- 业务模块只依赖 `@sfmc-bds/sdk` 与 `@minecraft/*`;跨模块协作通过 manifest 与 service/tx。 -- 玩家消息使用 `Msg.*`;动态 SQL 使用项目提供的安全构造器,禁止拼接不可信输入。 - 修改公共 API、发布包行为或版本契约时,检查是否需要 changeset。 ## 工作方式 -- 修复问题前先追踪真实调用链、注册时机、权限与生命周期,确认根因后再改代码。 -- 优先复用现有实现;不要为了“更完整”扩大重构范围。 - 默认不新增常驻测试脚本;功能完成后清理临时验证脚本,保留必要的构建、类型检查与运行验收记录。 - 格式遵循仓库 Prettier/ESLint 配置,不在规则文件中重复维护格式参数。 -## 验证 - -- 运行与改动范围相称的最小检查;优先使用目标包的 `typecheck` 或 `build`,必要时做一次性运行验收。 -- 跨包或架构变更再运行根目录 `pnpm run typecheck`、`pnpm run lint`、`pnpm run verify` 或 `pnpm run build` 中相关项。 -- 未运行或未通过的检查必须明确说明,不得声称修复完成。 - ## 按需资料 - 架构与约定:`docs/zh/dev/architecture.md`、`docs/zh/dev/conventions.md` - 模块开发:`docs/zh/dev/module-author.mdx` - 验证与联调:`docs/zh/dev/testing.md` - 文档站任务:`website/AGENTS.md` -- 场景化流程:`.cursor/skills/` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19922a42..e1a8d3ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,8 +6,6 @@ **[docs/dev/contributing.md](./docs/dev/contributing.md)** -快速自检: - ```bash pnpm install && pnpm run build && pnpm run verify ``` diff --git a/README.md b/README.md index a384fd2e..40ca3662 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,10 @@ [![version](https://img.shields.io/github/v/tag/DogeLakeDev/ScriptsForMinecraftServer?style=flat-square&label=version)](https://github.com/DogeLakeDev/ScriptsForMinecraftServer/tags) [![license](https://img.shields.io/github/license/DogeLakeDev/ScriptsForMinecraftServer?style=flat-square)](./LICENSE) -[![node](https://img.shields.io/badge/node-22.13%2B-339933?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org) [![typescript](https://img.shields.io/badge/TypeScript-6.0.2-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org) [![npm](https://img.shields.io/badge/npm-@sfmc--bds%2Fsfmc-CB3837?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@sfmc-bds/sfmc) -[![modules](https://img.shields.io/badge/modules-25-7B68EE?style=flat-square&logo=cube&logoColor=white)](./modules/catalog.json) -[![bd](https://img.shields.io/badge/BDS-1.26.x-00BC8C?style=flat-square&logo=minecraft)](https://www.minecraft.net/en-us/download/server/bedrock) -[模块仓库 →](https://github.com/Tanya7z/sfmc-modules) - -[English Version →](./README.en.md) +[模块仓库](https://github.com/Tanya7z/sfmc-modules) [English Version](./README.en.md) ## Scripts For Minecraft Server > 一个面向 Minecraft 基岩版服务器的模块化开发框架与管理平台。让开发者可以使用现代化工程流程构建、部署和维护 Minecraft 服务端功能。 @@ -41,32 +36,18 @@ SFMC 希望通过模块化架构补充基岩版的原生开发体验。 node -v # 安装 SFMC CLI 正式版 -pnpm add -g @sfmc-bds/sfmc@0.2.2 - -# 创建服务器目录 -mkdir my-server && cd my-server +pnpm add -g @sfmc-bds/sfmc # 初始化 SFMC sfmc ``` -pnpm v11 起默认会等待新版本发布满 24 小时。发布当天若省略版本号,可能装到上一正式版;可用 `pnpm view @sfmc-bds/sfmc version` 查看当前 `latest`,再指定该精确版本安装。 - 开发者可以直接克隆本 monorepo,详细内容请查看 [入门指南](./docs/zh/guide/index.mdx)。 ## 文档 -- 在线文档 - -- 使用指南 [docs/zh/guide](./docs/zh/guide/index.mdx) - -- 开发指南 [docs/zh/dev](./docs/zh/dev/index.mdx) - - [贡献指南](./docs/zh/dev/contributing.md) - -- API 文档 [docs/zh/api](./docs/zh/api/index.mdx) - -- SDK 类型参考 [docs/zh/reference](./docs/zh/reference/index.md) +- 在线文档 ## 许可证 @@ -77,9 +58,4 @@ SFMC 整体平台采用 [AGPL-3.0](./LICENSE) 许可证。 - **自由使用**:你可以运行、复制、分发和修改程序,同时保留这些自由。 - **Copyleft(AGPL 部分)**:如果你分发修改后的平台或服务版本,必须以相同许可证提供完整对应源代码。 -- **模块仓库**:独立仓库中的业务模块可以自行选择许可证。通过 ISC 授权 - SDK 开发模块不会自动使模块受到 AGPL 限制。 - ---- - -[English Version →](./README.en.md) +- **模块仓库**:独立仓库中的业务模块可以自行选择许可证。 diff --git a/_sandbox_archive/testing/engine/generated/export-names-ui.json b/_sandbox_archive/testing/engine/generated/export-names-ui.json deleted file mode 100644 index 610d389f..00000000 --- a/_sandbox_archive/testing/engine/generated/export-names-ui.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "module": "@minecraft/server-ui", - "dtsPath": "node_modules/@minecraft/server-ui/index.d.ts", - "totalValueExports": 26, - "generatedNames": [ - "ActionFormResponse", - "FormRejectError", - "FormRejectReason", - "FormResponse", - "FormVisibilityError", - "FormVisibilityErrorReason", - "InvalidFormError", - "InvalidFormModificationError", - "InvalidObservableError", - "MessageFormResponse", - "ModalFormResponse", - "PlayerLeftError", - "TextFilteringError", - "UIManager" - ], - "skippedOverrides": [ - "ActionFormData", - "CustomForm", - "DataDrivenScreenClosedReason", - "FormCancelationReason", - "MessageBox", - "MessageFormData", - "ModalFormData", - "ObservableBoolean", - "ObservableNumber", - "ObservableString", - "ObservableUIRawMessage", - "default", - "uiManager" - ] -} diff --git a/_sandbox_archive/testing/engine/generated/export-names.json b/_sandbox_archive/testing/engine/generated/export-names.json deleted file mode 100644 index d70bd33e..00000000 --- a/_sandbox_archive/testing/engine/generated/export-names.json +++ /dev/null @@ -1,569 +0,0 @@ -{ - "module": "@minecraft/server", - "dtsPath": "node_modules/@minecraft/server/index.d.ts", - "totalValueExports": 559, - "generatedNames": [ - "AimAssistCategory", - "AimAssistCategorySettings", - "AimAssistPreset", - "AimAssistPresetSettings", - "AimAssistRegistry", - "AimAssistTargetMode", - "BannerPattern", - "BiomeType", - "BiomeTypes", - "Block", - "BlockBoundingBoxUtils", - "BlockComponent", - "BlockComponentBlockBreakEvent", - "BlockComponentBlockStateChangeEvent", - "BlockComponentEntityEvent", - "BlockComponentEntityFallOnEvent", - "BlockComponentOnPlaceEvent", - "BlockComponentPlayerBreakEvent", - "BlockComponentPlayerInteractEvent", - "BlockComponentPlayerPlaceBeforeEvent", - "BlockComponentRandomTickEvent", - "BlockComponentRedstoneUpdateEvent", - "BlockComponentRegistry", - "BlockComponentStepOffEvent", - "BlockComponentStepOnEvent", - "BlockComponentTickEvent", - "BlockContainerClosedAfterEvent", - "BlockContainerClosedAfterEventSignal", - "BlockContainerOpenedAfterEvent", - "BlockContainerOpenedAfterEventSignal", - "BlockCustomComponentAlreadyRegisteredError", - "BlockCustomComponentInstance", - "BlockCustomComponentReloadNewComponentError", - "BlockCustomComponentReloadNewEventError", - "BlockCustomComponentReloadVersionError", - "BlockDynamicPropertiesComponent", - "BlockEvent", - "BlockExplodeAfterEvent", - "BlockExplodeAfterEventSignal", - "BlockFluidContainerComponent", - "BlockInstrumentComponent", - "BlockInventoryComponent", - "BlockLocationIterator", - "BlockMapColorComponent", - "BlockMovableComponent", - "BlockPistonComponent", - "BlockPistonState", - "BlockPrecipitationInteractionsComponent", - "BlockRecordPlayerComponent", - "BlockRedstoneProducerComponent", - "BlockSignComponent", - "BlockStates", - "BlockStateType", - "BlockType", - "BlockTypes", - "BlockVolume", - "BlockVolumeBase", - "BlockVolumeIntersection", - "BookError", - "BookErrorReason", - "BookPageContentError", - "ButtonPushAfterEvent", - "ButtonPushAfterEventSignal", - "ButtonState", - "Camera", - "CameraShakeType", - "CarryOverBlockEntityDataFunction", - "CatmullRomSpline", - "ChatSendAfterEvent", - "ChatSendAfterEventSignal", - "ChatSendBeforeEvent", - "ChatSendBeforeEventSignal", - "ClientSystemInfo", - "CloneMode", - "CommandError", - "CommandPermissionLevel", - "CommandResult", - "Component", - "CompoundBlockVolume", - "CompoundBlockVolumeAction", - "CompoundBlockVolumePositionRelativity", - "Container", - "ContainerRulesError", - "ContainerRulesErrorReason", - "ContainerSlot", - "ControlScheme", - "CustomCommandError", - "CustomCommandErrorReason", - "CustomCommandOrigin", - "CustomCommandParamType", - "CustomCommandRegistry", - "CustomCommandSource", - "CustomCommandStatus", - "CustomComponentInvalidRegistryError", - "CustomComponentNameError", - "CustomComponentNameErrorReason", - "CustomComponentParameters", - "CustomDimensionAlreadyRegisteredError", - "CustomDimensionInvalidRegistryError", - "CustomDimensionNameError", - "CustomDimensionReloadNewDimensionError", - "DamagedByEntityCondition", - "DataDrivenEntityTriggerAfterEvent", - "DataDrivenEntityTriggerAfterEventSignal", - "Difficulty", - "DimensionRegistry", - "DimensionType", - "DimensionTypes", - "Direction", - "DisplaySlotId", - "DyeColor", - "EasingType", - "EffectAddAfterEvent", - "EffectAddAfterEventSignal", - "EffectAddBeforeEvent", - "EffectAddBeforeEventSignal", - "EffectType", - "EffectTypes", - "EmptyLootItem", - "EnchantInfo", - "EnchantmentLevelOutOfBoundsError", - "EnchantmentSlot", - "EnchantmentType", - "EnchantmentTypeNotCompatibleError", - "EnchantmentTypes", - "EnchantmentTypeUnknownIdError", - "EnchantRandomEquipmentFunction", - "EnchantRandomlyFunction", - "EnchantWithLevelsFunction", - "EntityAddRiderComponent", - "EntityAgeableComponent", - "EntityAttachPoint", - "EntityAttributeComponent", - "EntityBaseMovementComponent", - "EntityBreathableComponent", - "EntityCanClimbComponent", - "EntityCanFlyComponent", - "EntityCanPowerJumpComponent", - "EntityColor2Component", - "EntityColorComponent", - "EntityComponent", - "EntityComponentTypes", - "EntityContainerClosedAfterEvent", - "EntityContainerClosedAfterEventSignal", - "EntityContainerOpenedAfterEvent", - "EntityContainerOpenedAfterEventSignal", - "EntityDamageCause", - "EntityDefinitionFeedItem", - "EntityDieAfterEvent", - "EntityDieAfterEventSignal", - "EntityEnderInventoryComponent", - "EntityEquippableComponent", - "EntityExhaustionComponent", - "EntityFireImmuneComponent", - "EntityFloatsInLiquidComponent", - "EntityFlyingSpeedComponent", - "EntityFrictionModifierComponent", - "EntityHasMarkVariantCondition", - "EntityHasVariantCondition", - "EntityHealableComponent", - "EntityHealAfterEvent", - "EntityHealAfterEventSignal", - "EntityHealBeforeEvent", - "EntityHealBeforeEventSignal", - "EntityHealCause", - "EntityHealSource", - "EntityHealthChangedAfterEvent", - "EntityHealthChangedAfterEventSignal", - "EntityHitBlockAfterEvent", - "EntityHitBlockAfterEventSignal", - "EntityHitEntityAfterEvent", - "EntityHitEntityAfterEventSignal", - "EntityHungerComponent", - "EntityHurtAfterEvent", - "EntityHurtAfterEventSignal", - "EntityHurtBeforeEvent", - "EntityHurtBeforeEventSignal", - "EntityIsBabyComponent", - "EntityIsChargedComponent", - "EntityIsChestedComponent", - "EntityIsDyeableComponent", - "EntityIsHiddenWhenInvisibleComponent", - "EntityIsIgnitedComponent", - "EntityIsIllagerCaptainComponent", - "EntityIsSaddledComponent", - "EntityIsShakingComponent", - "EntityIsShearedComponent", - "EntityIsStackableComponent", - "EntityIsStunnedComponent", - "EntityIsTamedComponent", - "EntityItemComponent", - "EntityItemDropAfterEvent", - "EntityItemDropAfterEventSignal", - "EntityItemPickupAfterEvent", - "EntityItemPickupAfterEventSignal", - "EntityItemPickupBeforeEvent", - "EntityItemPickupBeforeEventSignal", - "EntityKilledCondition", - "EntityLavaMovementComponent", - "EntityLeashableComponent", - "EntityLoadAfterEvent", - "EntityLoadAfterEventSignal", - "EntityMarkVariantComponent", - "EntityMovementAmphibiousComponent", - "EntityMovementBasicComponent", - "EntityMovementComponent", - "EntityMovementFlyComponent", - "EntityMovementGenericComponent", - "EntityMovementGlideComponent", - "EntityMovementHoverComponent", - "EntityMovementJumpComponent", - "EntityMovementSkipComponent", - "EntityMovementSwayComponent", - "EntityNavigationClimbComponent", - "EntityNavigationComponent", - "EntityNavigationFloatComponent", - "EntityNavigationFlyComponent", - "EntityNavigationGenericComponent", - "EntityNavigationHoverComponent", - "EntityNavigationWalkComponent", - "EntityNpcComponent", - "EntityOnFireComponent", - "EntityProjectileComponent", - "EntityPushThroughComponent", - "EntityRemoveAfterEvent", - "EntityRemoveAfterEventSignal", - "EntityRemoveBeforeEvent", - "EntityRemoveBeforeEventSignal", - "EntityRideableComponent", - "EntityRidingComponent", - "EntitySaturationComponent", - "EntityScaleComponent", - "EntitySkinIdComponent", - "EntitySpawnAfterEvent", - "EntitySpawnAfterEventSignal", - "EntitySpawnError", - "EntityStartSneakingAfterEvent", - "EntityStartSneakingAfterEventSignal", - "EntityStopSneakingAfterEvent", - "EntityStopSneakingAfterEventSignal", - "EntityStrengthComponent", - "EntitySwingSource", - "EntityTameableComponent", - "EntityTamedAfterEvent", - "EntityTamedAfterEventSignal", - "EntityTamedBeforeEvent", - "EntityTamedBeforeEventSignal", - "EntityTameMountComponent", - "EntityType", - "EntityTypeFamilyComponent", - "EntityTypes", - "EntityUnderwaterMovementComponent", - "EntityUpgradeAfterEvent", - "EntityUpgradeAfterEventSignal", - "EntityVariantComponent", - "EntityWantsJockeyComponent", - "EntityWaypoint", - "EquipmentSlot", - "ExplorationMapFunction", - "ExplosionAfterEvent", - "ExplosionAfterEventSignal", - "ExplosionBeforeEvent", - "ExplosionBeforeEventSignal", - "ExplosionDecayFunction", - "FeedItem", - "FeedItemEffect", - "FillContainerFunction", - "FluidContainer", - "FluidType", - "FogSettings", - "FogSettingsError", - "GameRule", - "GameRuleChangeAfterEvent", - "GameRuleChangeAfterEventSignal", - "GameRules", - "GraphicsMode", - "HeldItemOption", - "HudElement", - "HudElementsCount", - "HudVisibility", - "HudVisibilityCount", - "InputButton", - "InputInfo", - "InputMode", - "InputPermissionCategory", - "InvalidBlockComponentError", - "InvalidContainerError", - "InvalidContainerSlotError", - "InvalidEntityComponentError", - "InvalidEntityError", - "InvalidItemStackError", - "InvalidIteratorError", - "InvalidPotionDeliveryTypeError", - "InvalidPotionEffectTypeError", - "InvalidStructureError", - "InvalidWaypointError", - "InvalidWaypointTextureSelectorError", - "IsBabyCondition", - "ISerializable", - "ItemBlockDynamicPropertiesComponent", - "ItemBookComponent", - "ItemCompleteUseAfterEvent", - "ItemCompleteUseAfterEventSignal", - "ItemCompleteUseEvent", - "ItemComponent", - "ItemComponentBeforeDurabilityDamageEvent", - "ItemComponentCompleteUseEvent", - "ItemComponentConsumeEvent", - "ItemComponentHitEntityEvent", - "ItemComponentMineBlockEvent", - "ItemComponentRegistry", - "ItemComponentTypes", - "ItemComponentUseEvent", - "ItemComponentUseOnEvent", - "ItemCompostableComponent", - "ItemCooldownComponent", - "ItemCustomComponentAlreadyRegisteredError", - "ItemCustomComponentInstance", - "ItemCustomComponentReloadNewComponentError", - "ItemCustomComponentReloadNewEventError", - "ItemCustomComponentReloadVersionError", - "ItemDurabilityComponent", - "ItemDyeableComponent", - "ItemEnchantableComponent", - "ItemFoodComponent", - "ItemInventoryComponent", - "ItemLockMode", - "ItemPotionComponent", - "ItemReleaseUseAfterEvent", - "ItemReleaseUseAfterEventSignal", - "ItemStartUseAfterEvent", - "ItemStartUseAfterEventSignal", - "ItemStartUseOnAfterEvent", - "ItemStartUseOnAfterEventSignal", - "ItemStopUseAfterEvent", - "ItemStopUseAfterEventSignal", - "ItemStopUseOnAfterEvent", - "ItemStopUseOnAfterEventSignal", - "ItemType", - "ItemTypes", - "ItemUseAfterEvent", - "ItemUseAfterEventSignal", - "ItemUseBeforeEvent", - "ItemUseBeforeEventSignal", - "ItemUseOnEvent", - "KilledByEntityCondition", - "KilledByPlayerCondition", - "KilledByPlayerOrPetsCondition", - "LeverActionAfterEvent", - "LeverActionAfterEventSignal", - "LinearSpline", - "LiquidSettings", - "LiquidType", - "ListBlockVolume", - "LocationInUnloadedChunkError", - "LocationOutOfWorldBoundariesError", - "LocationWaypoint", - "LocatorBar", - "LocatorBarError", - "LocatorBarErrorReason", - "LootingEnchantFunction", - "LootItem", - "LootItemCondition", - "LootItemFunction", - "LootPool", - "LootPoolEntry", - "LootPoolTiers", - "LootTable", - "LootTableEntry", - "LootTableManager", - "LootTableReference", - "MatchToolCondition", - "MemoryTier", - "MessageReceiveAfterEvent", - "MolangVariableMap", - "MoonPhase", - "MoonPhaseCount", - "MovementType", - "NamespaceNameError", - "NamespaceNameErrorReason", - "ObjectiveSortOrder", - "PackSettingChangeAfterEvent", - "PackSettingChangeAfterEventSignal", - "PaletteColor", - "PassengerOfEntityCondition", - "PistonActivateAfterEvent", - "PistonActivateAfterEventSignal", - "PlaceJigsawError", - "PlatformType", - "PlayerAimAssist", - "PlayerBreakBlockAfterEvent", - "PlayerBreakBlockAfterEventSignal", - "PlayerBreakBlockBeforeEvent", - "PlayerBreakBlockBeforeEventSignal", - "PlayerButtonInputAfterEvent", - "PlayerButtonInputAfterEventSignal", - "PlayerCancelBreakingBlockAfterEvent", - "PlayerCancelBreakingBlockAfterEventSignal", - "PlayerCursorInventoryComponent", - "PlayerDimensionChangeAfterEvent", - "PlayerDimensionChangeAfterEventSignal", - "PlayerEmoteAfterEvent", - "PlayerEmoteAfterEventSignal", - "PlayerGameModeChangeAfterEvent", - "PlayerGameModeChangeAfterEventSignal", - "PlayerGameModeChangeBeforeEvent", - "PlayerGameModeChangeBeforeEventSignal", - "PlayerHotbarSelectedSlotChangeAfterEvent", - "PlayerHotbarSelectedSlotChangeAfterEventSignal", - "PlayerInputModeChangeAfterEvent", - "PlayerInputModeChangeAfterEventSignal", - "PlayerInputPermissionCategoryChangeAfterEvent", - "PlayerInputPermissionCategoryChangeAfterEventSignal", - "PlayerInputPermissions", - "PlayerInteractWithBlockAfterEvent", - "PlayerInteractWithBlockAfterEventSignal", - "PlayerInteractWithBlockBeforeEvent", - "PlayerInteractWithBlockBeforeEventSignal", - "PlayerInteractWithEntityAfterEvent", - "PlayerInteractWithEntityAfterEventSignal", - "PlayerInteractWithEntityBeforeEvent", - "PlayerInteractWithEntityBeforeEventSignal", - "PlayerInventoryItemChangeAfterEvent", - "PlayerInventoryItemChangeAfterEventSignal", - "PlayerInventoryType", - "PlayerJoinAfterEvent", - "PlayerJoinAfterEventSignal", - "PlayerLeaveAfterEvent", - "PlayerLeaveAfterEventSignal", - "PlayerLeaveBeforeEvent", - "PlayerLeaveBeforeEventSignal", - "PlayerPlaceBlockAfterEvent", - "PlayerPlaceBlockAfterEventSignal", - "PlayerPlaceBlockBeforeEvent", - "PlayerPlaceBlockBeforeEventSignal", - "PlayerSpawnAfterEvent", - "PlayerSpawnAfterEventSignal", - "PlayerSplitScreenSlot", - "PlayerStartBreakingBlockAfterEvent", - "PlayerStartBreakingBlockAfterEventSignal", - "PlayerSwingStartAfterEvent", - "PlayerSwingStartAfterEventSignal", - "PlayerUseNameTagAfterEvent", - "PlayerUseNameTagAfterEventSignal", - "PlayerWaypoint", - "PlayerWaypointsMode", - "PotionDeliveryType", - "PotionEffectType", - "Potions", - "PressurePlatePopAfterEvent", - "PressurePlatePopAfterEventSignal", - "PressurePlatePushAfterEvent", - "PressurePlatePushAfterEventSignal", - "PrimitiveShape", - "PrimitiveShapeError", - "PrimitiveShapesManager", - "ProjectileHitBlockAfterEvent", - "ProjectileHitBlockAfterEventSignal", - "ProjectileHitEntityAfterEvent", - "ProjectileHitEntityAfterEventSignal", - "RandomAuxValueFunction", - "RandomBlockStateFunction", - "RandomChanceCondition", - "RandomChanceWithLootingCondition", - "RandomDifficultyChanceCondition", - "RandomDyeFunction", - "RandomRegionalDifficultyChanceCondition", - "RawMessageError", - "Scoreboard", - "ScoreboardIdentity", - "ScoreboardIdentityType", - "ScoreboardObjective", - "ScoreboardScoreInfo", - "ScreenDisplay", - "ScriptEventCommandMessageAfterEvent", - "ScriptEventCommandMessageAfterEventSignal", - "ScriptEventSource", - "Seat", - "ServerMessageAfterEventSignal", - "SetArmorTrimFunction", - "SetBannerDetailsFunction", - "SetBookContentsFunction", - "SetDataFromColorIndexFunction", - "SetItemCountFunction", - "SetItemDamageFunction", - "SetItemDataFunction", - "SetItemLoreFunction", - "SetItemNameFunction", - "SetOminousBottleFunction", - "SetPotionFunction", - "SetSpawnEggFunction", - "SetStewEffectFunction", - "ShutdownBeforeEventSignal", - "ShutdownEvent", - "SignSide", - "SmeltItemFunction", - "SoundCompletedAfterEvent", - "SoundCompletedAfterEventSignal", - "SoundDefinition", - "SoundDefinitionRegistry", - "SoundDurationInfo", - "SoundInstance", - "SpecificEnchantFunction", - "StartupBeforeEventSignal", - "StartupEvent", - "StickyType", - "Structure", - "StructureAnimationMode", - "StructureManager", - "StructureMirrorAxis", - "StructureRotation", - "StructureSaveMode", - "System", - "SystemAfterEvents", - "SystemBeforeEvents", - "SystemInfo", - "TargetBlockHitAfterEvent", - "TargetBlockHitAfterEventSignal", - "TextPrimitive", - "TickingAreaError", - "TickingAreaErrorReason", - "TickingAreaManager", - "TicksPerDay", - "TicksPerSecond", - "TimeOfDay", - "TintMethod", - "Trigger", - "TripWireTripAfterEvent", - "TripWireTripAfterEventSignal", - "UnloadedChunksError", - "WatchdogTerminateBeforeEvent", - "WatchdogTerminateBeforeEventSignal", - "WatchdogTerminateReason", - "Waypoint", - "WaypointTexture", - "WeatherChangeAfterEvent", - "WeatherChangeAfterEventSignal", - "WeatherChangeBeforeEvent", - "WeatherChangeBeforeEventSignal", - "WeatherType", - "World", - "WorldAfterEvents", - "WorldBeforeEvents", - "WorldLoadAfterEvent", - "WorldLoadAfterEventSignal" - ], - "skippedOverrides": [ - "BlockComponentTypes", - "BlockPermutation", - "Dimension", - "Effect", - "Entity", - "EntityHealthComponent", - "EntityInitializationCause", - "EntityInventoryComponent", - "GameMode", - "ItemStack", - "Player", - "PlayerPermissionLevel", - "default", - "system", - "world" - ] -} diff --git a/_sandbox_archive/testing/engine/generated/l0-meta.ts b/_sandbox_archive/testing/engine/generated/l0-meta.ts deleted file mode 100644 index 03e93457..00000000 --- a/_sandbox_archive/testing/engine/generated/l0-meta.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * 由 scripts/gen-mc-fake.mjs 生成 — 勿手改。 - * 供 createSandbox().supported.l0 读取覆盖元数据。 - */ -export const SERVER_L0_META = { - module: "@minecraft/server" as const, - totalValueExports: 559, - generated: 545, - overridesSkipped: 15, -}; - -export const SERVER_UI_L0_META = { - module: "@minecraft/server-ui" as const, - totalValueExports: 26, - generated: 14, - overridesSkipped: 13, -}; diff --git a/_sandbox_archive/testing/engine/generated/playground-meta.ts b/_sandbox_archive/testing/engine/generated/playground-meta.ts deleted file mode 100644 index 30e6e1cd..00000000 --- a/_sandbox_archive/testing/engine/generated/playground-meta.ts +++ /dev/null @@ -1,6557 +0,0 @@ -/** - * 由 scripts/gen-playground-meta.mjs 生成 — 勿手改。 - * Playground / sb.objects / sb.events 的 1:1 表面权威。 - */ - -export const PLAYGROUND_META = { - "generatedAt": "gen-playground-meta", - "classes": { - "Player": { - "properties": [ - { - "name": "camera", - "readonly": true, - "type": "Camera", - "impl": "l0" - }, - { - "name": "chatDisplayName", - "readonly": true, - "type": "string", - "impl": "l0" - }, - { - "name": "chatMessagePrefix", - "readonly": false, - "type": "string", - "impl": "l2" - }, - { - "name": "chatNamePrefix", - "readonly": false, - "type": "string", - "impl": "l2" - }, - { - "name": "chatNameSuffix", - "readonly": false, - "type": "string", - "impl": "l2" - }, - { - "name": "clientSystemInfo", - "readonly": true, - "type": "ClientSystemInfo", - "impl": "skip" - }, - { - "name": "commandPermissionLevel", - "readonly": false, - "type": "CommandPermissionLevel", - "impl": "l2" - }, - { - "name": "dimension", - "readonly": true, - "type": "Dimension", - "impl": "l2" - }, - { - "name": "fogSettings", - "readonly": true, - "type": "FogSettings", - "impl": "l0" - }, - { - "name": "graphicsMode", - "readonly": true, - "type": "GraphicsMode", - "impl": "skip" - }, - { - "name": "id", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "inputInfo", - "readonly": true, - "type": "InputInfo", - "impl": "skip" - }, - { - "name": "inputPermissions", - "readonly": true, - "type": "PlayerInputPermissions", - "impl": "l0" - }, - { - "name": "isClimbing", - "readonly": true, - "type": "boolean", - "impl": "l0" - }, - { - "name": "isEmoting", - "readonly": true, - "type": "boolean", - "impl": "skip" - }, - { - "name": "isFalling", - "readonly": true, - "type": "boolean", - "impl": "l0" - }, - { - "name": "isFlying", - "readonly": true, - "type": "boolean", - "impl": "skip" - }, - { - "name": "isGliding", - "readonly": true, - "type": "boolean", - "impl": "skip" - }, - { - "name": "isInWater", - "readonly": true, - "type": "boolean", - "impl": "l0" - }, - { - "name": "isJumping", - "readonly": true, - "type": "boolean", - "impl": "skip" - }, - { - "name": "isOnGround", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "isSleeping", - "readonly": true, - "type": "boolean", - "impl": "skip" - }, - { - "name": "isSneaking", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "isSprinting", - "readonly": true, - "type": "boolean", - "impl": "skip" - }, - { - "name": "isSwimming", - "readonly": true, - "type": "boolean", - "impl": "l0" - }, - { - "name": "isValid", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "level", - "readonly": true, - "type": "number", - "impl": "l0" - }, - { - "name": "localizationKey", - "readonly": true, - "type": "string", - "impl": "l0" - }, - { - "name": "location", - "readonly": true, - "type": "Vector3", - "impl": "l2" - }, - { - "name": "locatorBar", - "readonly": true, - "type": "LocatorBar", - "impl": "l0" - }, - { - "name": "name", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "nameplateDepthTested", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "nameplateRenderDistance", - "readonly": false, - "type": "number", - "impl": "l2" - }, - { - "name": "nameTag", - "readonly": false, - "type": "string", - "impl": "l2" - }, - { - "name": "onScreenDisplay", - "readonly": true, - "type": "ScreenDisplay", - "impl": "l2" - }, - { - "name": "persistentId", - "readonly": true, - "type": "string", - "impl": "l0" - }, - { - "name": "playerPermissionLevel", - "readonly": true, - "type": "PlayerPermissionLevel", - "impl": "l2" - }, - { - "name": "scoreboardIdentity", - "readonly": true, - "type": "ScoreboardIdentity", - "impl": "l2" - }, - { - "name": "selectedSlotIndex", - "readonly": false, - "type": "number", - "impl": "l2" - }, - { - "name": "target", - "readonly": true, - "type": "Entity", - "impl": "l0" - }, - { - "name": "totalXpNeededForNextLevel", - "readonly": true, - "type": "number", - "impl": "l0" - }, - { - "name": "typeId", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "xpEarnedAtCurrentLevel", - "readonly": true, - "type": "number", - "impl": "l0" - } - ], - "methods": [ - { - "name": "addEffect", - "parameters": [ - { - "name": "effectType", - "optional": false, - "type": "EffectType | string", - "rest": false - }, - { - "name": "duration", - "optional": false, - "type": "number", - "rest": false - }, - { - "name": "options", - "optional": true, - "type": "EntityEffectOptions", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "addExperience", - "parameters": [ - { - "name": "amount", - "optional": false, - "type": "number", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "addItem", - "parameters": [ - { - "name": "itemStack", - "optional": false, - "type": "ItemStack", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "addLevels", - "parameters": [ - { - "name": "amount", - "optional": false, - "type": "number", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "addTag", - "parameters": [ - { - "name": "tag", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "applyDamage", - "parameters": [ - { - "name": "amount", - "optional": false, - "type": "number", - "rest": false - }, - { - "name": "options", - "optional": true, - "type": "EntityApplyDamageByProjectileOptions | EntityApplyDamageOptions", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "applyImpulse", - "parameters": [ - { - "name": "vector", - "optional": false, - "type": "Vector3", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "applyKnockback", - "parameters": [ - { - "name": "horizontalForce", - "optional": false, - "type": "VectorXZ", - "rest": false - }, - { - "name": "verticalStrength", - "optional": false, - "type": "number", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "clearDynamicProperties", - "parameters": [], - "impl": "l2" - }, - { - "name": "clearPropertyOverridesForEntity", - "parameters": [ - { - "name": "targetEntity", - "optional": false, - "type": "Entity | string", - "rest": false - } - ], - "impl": "skip" - }, - { - "name": "clearVelocity", - "parameters": [], - "impl": "l0" - }, - { - "name": "eatItem", - "parameters": [ - { - "name": "itemStack", - "optional": false, - "type": "ItemStack", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "extinguishFire", - "parameters": [ - { - "name": "useEffects", - "optional": true, - "type": "boolean", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getAABB", - "parameters": [], - "impl": "l0" - }, - { - "name": "getAimAssist", - "parameters": [], - "impl": "skip" - }, - { - "name": "getAllBlocksStandingOn", - "parameters": [ - { - "name": "options", - "optional": true, - "type": "GetBlocksStandingOnOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getBlockFromViewDirection", - "parameters": [ - { - "name": "options", - "optional": true, - "type": "BlockRaycastOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getBlockStandingOn", - "parameters": [ - { - "name": "options", - "optional": true, - "type": "GetBlocksStandingOnOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getComponent", - "parameters": [ - { - "name": "componentId", - "optional": false, - "type": "T", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getComponents", - "parameters": [], - "impl": "l2" - }, - { - "name": "getControlScheme", - "parameters": [], - "impl": "skip" - }, - { - "name": "getDynamicProperty", - "parameters": [ - { - "name": "identifier", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getDynamicPropertyIds", - "parameters": [], - "impl": "l2" - }, - { - "name": "getDynamicPropertyTotalByteCount", - "parameters": [], - "impl": "l2" - }, - { - "name": "getEffect", - "parameters": [ - { - "name": "effectType", - "optional": false, - "type": "EffectType | string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getEffects", - "parameters": [], - "impl": "l2" - }, - { - "name": "getEntitiesFromViewDirection", - "parameters": [ - { - "name": "options", - "optional": true, - "type": "EntityRaycastOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getGameMode", - "parameters": [], - "impl": "l2" - }, - { - "name": "getHeadLocation", - "parameters": [], - "impl": "l2" - }, - { - "name": "getItemCooldown", - "parameters": [ - { - "name": "cooldownCategory", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getPing", - "parameters": [], - "impl": "skip" - }, - { - "name": "getProperty", - "parameters": [ - { - "name": "identifier", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getRotation", - "parameters": [], - "impl": "l2" - }, - { - "name": "getSpawnPoint", - "parameters": [], - "impl": "l2" - }, - { - "name": "getSplitScreenSlot", - "parameters": [], - "impl": "l0" - }, - { - "name": "getTags", - "parameters": [], - "impl": "l2" - }, - { - "name": "getTotalXp", - "parameters": [], - "impl": "l0" - }, - { - "name": "getVelocity", - "parameters": [], - "impl": "l2" - }, - { - "name": "getViewDirection", - "parameters": [], - "impl": "l0" - }, - { - "name": "hasComponent", - "parameters": [ - { - "name": "componentId", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "hasTag", - "parameters": [ - { - "name": "tag", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "kill", - "parameters": [], - "impl": "l2" - }, - { - "name": "lookAt", - "parameters": [ - { - "name": "targetLocation", - "optional": false, - "type": "Vector3", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "matches", - "parameters": [ - { - "name": "options", - "optional": false, - "type": "EntityQueryOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "playAnimation", - "parameters": [ - { - "name": "animationName", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "options", - "optional": true, - "type": "PlayAnimationOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "playMusic", - "parameters": [ - { - "name": "trackId", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "musicOptions", - "optional": true, - "type": "MusicOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "playSound", - "parameters": [ - { - "name": "soundId", - "optional": false, - "type": "SoundDefinition | string", - "rest": false - }, - { - "name": "soundOptions", - "optional": true, - "type": "PlayerSoundOptions", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "postClientMessage", - "parameters": [ - { - "name": "id", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "value", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "queueMusic", - "parameters": [ - { - "name": "trackId", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "musicOptions", - "optional": true, - "type": "MusicOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "remove", - "parameters": [], - "impl": "l2" - }, - { - "name": "removeEffect", - "parameters": [ - { - "name": "effectType", - "optional": false, - "type": "EffectType | string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "removePropertyOverrideForEntity", - "parameters": [ - { - "name": "targetEntity", - "optional": false, - "type": "Entity", - "rest": false - }, - { - "name": "identifier", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "removeTag", - "parameters": [ - { - "name": "tag", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "resetLevel", - "parameters": [], - "impl": "l0" - }, - { - "name": "resetProperty", - "parameters": [ - { - "name": "identifier", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "runCommand", - "parameters": [ - { - "name": "commandString", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "sendMessage", - "parameters": [ - { - "name": "message", - "optional": false, - "type": "(RawMessage | string)[] | RawMessage | string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setControlScheme", - "parameters": [ - { - "name": "controlScheme", - "optional": true, - "type": "ControlScheme", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "setDynamicProperties", - "parameters": [ - { - "name": "values", - "optional": false, - "type": "Record", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setDynamicProperty", - "parameters": [ - { - "name": "identifier", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "value", - "optional": true, - "type": "boolean | number | string | Vector3", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setGameMode", - "parameters": [ - { - "name": "gameMode", - "optional": true, - "type": "GameMode", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setOnFire", - "parameters": [ - { - "name": "seconds", - "optional": false, - "type": "number", - "rest": false - }, - { - "name": "useEffects", - "optional": true, - "type": "boolean", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "setProperty", - "parameters": [ - { - "name": "identifier", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "value", - "optional": false, - "type": "boolean | number | string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "setPropertyOverrideForEntity", - "parameters": [ - { - "name": "targetEntity", - "optional": false, - "type": "Entity", - "rest": false - }, - { - "name": "identifier", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "value", - "optional": false, - "type": "boolean | number | string", - "rest": false - } - ], - "impl": "skip" - }, - { - "name": "setRotation", - "parameters": [ - { - "name": "rotation", - "optional": false, - "type": "Vector2", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "setSpawnPoint", - "parameters": [ - { - "name": "spawnPoint", - "optional": true, - "type": "DimensionLocation", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "spawnParticle", - "parameters": [ - { - "name": "effectName", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "molangVariables", - "optional": true, - "type": "MolangVariableMap", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "startItemCooldown", - "parameters": [ - { - "name": "cooldownCategory", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "tickDuration", - "optional": false, - "type": "number", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "stopAllSounds", - "parameters": [], - "impl": "l0" - }, - { - "name": "stopMusic", - "parameters": [], - "impl": "l0" - }, - { - "name": "stopSound", - "parameters": [ - { - "name": "soundId", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "teleport", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "teleportOptions", - "optional": true, - "type": "TeleportOptions", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "triggerEvent", - "parameters": [ - { - "name": "eventName", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "tryTeleport", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "teleportOptions", - "optional": true, - "type": "TeleportOptions", - "rest": false - } - ], - "impl": "l0" - } - ], - "kind": "object", - "extends": "Entity" - }, - "Entity": { - "properties": [ - { - "name": "dimension", - "readonly": true, - "type": "Dimension", - "impl": "l2" - }, - { - "name": "id", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "isClimbing", - "readonly": true, - "type": "boolean", - "impl": "l0" - }, - { - "name": "isFalling", - "readonly": true, - "type": "boolean", - "impl": "l0" - }, - { - "name": "isInWater", - "readonly": true, - "type": "boolean", - "impl": "l0" - }, - { - "name": "isOnGround", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "isSleeping", - "readonly": true, - "type": "boolean", - "impl": "skip" - }, - { - "name": "isSneaking", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "isSprinting", - "readonly": true, - "type": "boolean", - "impl": "skip" - }, - { - "name": "isSwimming", - "readonly": true, - "type": "boolean", - "impl": "l0" - }, - { - "name": "isValid", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "localizationKey", - "readonly": true, - "type": "string", - "impl": "l0" - }, - { - "name": "location", - "readonly": true, - "type": "Vector3", - "impl": "l2" - }, - { - "name": "nameplateDepthTested", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "nameplateRenderDistance", - "readonly": false, - "type": "number", - "impl": "l2" - }, - { - "name": "nameTag", - "readonly": false, - "type": "string", - "impl": "l2" - }, - { - "name": "scoreboardIdentity", - "readonly": true, - "type": "ScoreboardIdentity", - "impl": "l2" - }, - { - "name": "target", - "readonly": true, - "type": "Entity", - "impl": "l0" - }, - { - "name": "typeId", - "readonly": true, - "type": "string", - "impl": "l2" - } - ], - "methods": [ - { - "name": "addEffect", - "parameters": [ - { - "name": "effectType", - "optional": false, - "type": "EffectType | string", - "rest": false - }, - { - "name": "duration", - "optional": false, - "type": "number", - "rest": false - }, - { - "name": "options", - "optional": true, - "type": "EntityEffectOptions", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "addItem", - "parameters": [ - { - "name": "itemStack", - "optional": false, - "type": "ItemStack", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "addTag", - "parameters": [ - { - "name": "tag", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "applyDamage", - "parameters": [ - { - "name": "amount", - "optional": false, - "type": "number", - "rest": false - }, - { - "name": "options", - "optional": true, - "type": "EntityApplyDamageByProjectileOptions | EntityApplyDamageOptions", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "applyImpulse", - "parameters": [ - { - "name": "vector", - "optional": false, - "type": "Vector3", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "applyKnockback", - "parameters": [ - { - "name": "horizontalForce", - "optional": false, - "type": "VectorXZ", - "rest": false - }, - { - "name": "verticalStrength", - "optional": false, - "type": "number", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "clearDynamicProperties", - "parameters": [], - "impl": "l2" - }, - { - "name": "clearVelocity", - "parameters": [], - "impl": "l0" - }, - { - "name": "extinguishFire", - "parameters": [ - { - "name": "useEffects", - "optional": true, - "type": "boolean", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getAABB", - "parameters": [], - "impl": "l0" - }, - { - "name": "getAllBlocksStandingOn", - "parameters": [ - { - "name": "options", - "optional": true, - "type": "GetBlocksStandingOnOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getBlockFromViewDirection", - "parameters": [ - { - "name": "options", - "optional": true, - "type": "BlockRaycastOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getBlockStandingOn", - "parameters": [ - { - "name": "options", - "optional": true, - "type": "GetBlocksStandingOnOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getComponent", - "parameters": [ - { - "name": "componentId", - "optional": false, - "type": "T", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getComponents", - "parameters": [], - "impl": "l2" - }, - { - "name": "getDynamicProperty", - "parameters": [ - { - "name": "identifier", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getDynamicPropertyIds", - "parameters": [], - "impl": "l2" - }, - { - "name": "getDynamicPropertyTotalByteCount", - "parameters": [], - "impl": "l2" - }, - { - "name": "getEffect", - "parameters": [ - { - "name": "effectType", - "optional": false, - "type": "EffectType | string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getEffects", - "parameters": [], - "impl": "l2" - }, - { - "name": "getEntitiesFromViewDirection", - "parameters": [ - { - "name": "options", - "optional": true, - "type": "EntityRaycastOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getHeadLocation", - "parameters": [], - "impl": "l2" - }, - { - "name": "getProperty", - "parameters": [ - { - "name": "identifier", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getRotation", - "parameters": [], - "impl": "l2" - }, - { - "name": "getTags", - "parameters": [], - "impl": "l2" - }, - { - "name": "getVelocity", - "parameters": [], - "impl": "l2" - }, - { - "name": "getViewDirection", - "parameters": [], - "impl": "l0" - }, - { - "name": "hasComponent", - "parameters": [ - { - "name": "componentId", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "hasTag", - "parameters": [ - { - "name": "tag", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "kill", - "parameters": [], - "impl": "l2" - }, - { - "name": "lookAt", - "parameters": [ - { - "name": "targetLocation", - "optional": false, - "type": "Vector3", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "matches", - "parameters": [ - { - "name": "options", - "optional": false, - "type": "EntityQueryOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "playAnimation", - "parameters": [ - { - "name": "animationName", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "options", - "optional": true, - "type": "PlayAnimationOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "remove", - "parameters": [], - "impl": "l2" - }, - { - "name": "removeEffect", - "parameters": [ - { - "name": "effectType", - "optional": false, - "type": "EffectType | string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "removeTag", - "parameters": [ - { - "name": "tag", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "resetProperty", - "parameters": [ - { - "name": "identifier", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "runCommand", - "parameters": [ - { - "name": "commandString", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setDynamicProperties", - "parameters": [ - { - "name": "values", - "optional": false, - "type": "Record", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setDynamicProperty", - "parameters": [ - { - "name": "identifier", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "value", - "optional": true, - "type": "boolean | number | string | Vector3", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setOnFire", - "parameters": [ - { - "name": "seconds", - "optional": false, - "type": "number", - "rest": false - }, - { - "name": "useEffects", - "optional": true, - "type": "boolean", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "setProperty", - "parameters": [ - { - "name": "identifier", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "value", - "optional": false, - "type": "boolean | number | string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "setRotation", - "parameters": [ - { - "name": "rotation", - "optional": false, - "type": "Vector2", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "teleport", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "teleportOptions", - "optional": true, - "type": "TeleportOptions", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "triggerEvent", - "parameters": [ - { - "name": "eventName", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "tryTeleport", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "teleportOptions", - "optional": true, - "type": "TeleportOptions", - "rest": false - } - ], - "impl": "l0" - } - ], - "kind": "object" - }, - "ItemStack": { - "properties": [ - { - "name": "amount", - "readonly": false, - "type": "number", - "impl": "l2" - }, - { - "name": "isStackable", - "readonly": true, - "type": "boolean", - "impl": "l0" - }, - { - "name": "keepOnDeath", - "readonly": false, - "type": "boolean", - "impl": "l0" - }, - { - "name": "localizationKey", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "lockMode", - "readonly": false, - "type": "ItemLockMode", - "impl": "l0" - }, - { - "name": "maxAmount", - "readonly": true, - "type": "number", - "impl": "l2" - }, - { - "name": "nameTag", - "readonly": false, - "type": "string", - "impl": "l2" - }, - { - "name": "typeId", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "weight", - "readonly": true, - "type": "number", - "impl": "l0" - } - ], - "methods": [ - { - "name": "clearDynamicProperties", - "parameters": [], - "impl": "l0" - }, - { - "name": "clone", - "parameters": [], - "impl": "l0" - }, - { - "name": "getCanDestroy", - "parameters": [], - "impl": "l0" - }, - { - "name": "getCanPlaceOn", - "parameters": [], - "impl": "l0" - }, - { - "name": "getComponent", - "parameters": [ - { - "name": "componentId", - "optional": false, - "type": "T", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getComponents", - "parameters": [], - "impl": "l0" - }, - { - "name": "getDynamicProperty", - "parameters": [ - { - "name": "identifier", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getDynamicPropertyIds", - "parameters": [], - "impl": "l0" - }, - { - "name": "getDynamicPropertyTotalByteCount", - "parameters": [], - "impl": "l0" - }, - { - "name": "getLore", - "parameters": [], - "impl": "l0" - }, - { - "name": "getRawLore", - "parameters": [], - "impl": "l0" - }, - { - "name": "getTags", - "parameters": [], - "impl": "l0" - }, - { - "name": "hasComponent", - "parameters": [ - { - "name": "componentId", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "hasTag", - "parameters": [ - { - "name": "tag", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "isStackableWith", - "parameters": [ - { - "name": "itemStack", - "optional": false, - "type": "ItemStack", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "matches", - "parameters": [ - { - "name": "itemName", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "states", - "optional": true, - "type": "Record", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "setCanDestroy", - "parameters": [ - { - "name": "blockIdentifiers", - "optional": true, - "type": "string[]", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "setCanPlaceOn", - "parameters": [ - { - "name": "blockIdentifiers", - "optional": true, - "type": "string[]", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "setDynamicProperties", - "parameters": [ - { - "name": "values", - "optional": false, - "type": "Record", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "setDynamicProperty", - "parameters": [ - { - "name": "identifier", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "value", - "optional": true, - "type": "boolean | number | string | Vector3", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "setLore", - "parameters": [ - { - "name": "loreList", - "optional": true, - "type": "(RawMessage | string)[]", - "rest": false - } - ], - "impl": "l0" - } - ], - "kind": "object" - }, - "Block": { - "properties": [ - { - "name": "dimension", - "readonly": true, - "type": "Dimension", - "impl": "l2" - }, - { - "name": "isAir", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "isLiquid", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "isSolid", - "readonly": true, - "type": "boolean", - "impl": "l0" - }, - { - "name": "isValid", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "isWaterlogged", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "localizationKey", - "readonly": true, - "type": "string", - "impl": "l0" - }, - { - "name": "location", - "readonly": true, - "type": "Vector3", - "impl": "l2" - }, - { - "name": "permutation", - "readonly": true, - "type": "BlockPermutation", - "impl": "l2" - }, - { - "name": "typeId", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "x", - "readonly": true, - "type": "number", - "impl": "l2" - }, - { - "name": "y", - "readonly": true, - "type": "number", - "impl": "l2" - }, - { - "name": "z", - "readonly": true, - "type": "number", - "impl": "l2" - } - ], - "methods": [ - { - "name": "above", - "parameters": [ - { - "name": "steps", - "optional": true, - "type": "number", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "below", - "parameters": [ - { - "name": "steps", - "optional": true, - "type": "number", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "bottomCenter", - "parameters": [], - "impl": "l0" - }, - { - "name": "canBeDestroyedByLiquidSpread", - "parameters": [ - { - "name": "liquidType", - "optional": false, - "type": "LiquidType", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "canContainLiquid", - "parameters": [ - { - "name": "liquidType", - "optional": false, - "type": "LiquidType", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "canPlace", - "parameters": [ - { - "name": "blockToPlace", - "optional": false, - "type": "BlockPermutation | BlockType | string", - "rest": false - }, - { - "name": "faceToPlaceOn", - "optional": true, - "type": "Direction", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "center", - "parameters": [], - "impl": "l0" - }, - { - "name": "east", - "parameters": [ - { - "name": "steps", - "optional": true, - "type": "number", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getComponent", - "parameters": [ - { - "name": "componentId", - "optional": false, - "type": "T", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getComponents", - "parameters": [], - "impl": "l0" - }, - { - "name": "getItemStack", - "parameters": [ - { - "name": "amount", - "optional": true, - "type": "number", - "rest": false - }, - { - "name": "withData", - "optional": true, - "type": "boolean", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getLightLevel", - "parameters": [], - "impl": "l0" - }, - { - "name": "getMapColor", - "parameters": [], - "impl": "l0" - }, - { - "name": "getParts", - "parameters": [], - "impl": "l0" - }, - { - "name": "getRedstonePower", - "parameters": [], - "impl": "l0" - }, - { - "name": "getSkyLightLevel", - "parameters": [], - "impl": "l0" - }, - { - "name": "getTags", - "parameters": [], - "impl": "l0" - }, - { - "name": "hasComponent", - "parameters": [ - { - "name": "componentId", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "hasTag", - "parameters": [ - { - "name": "tag", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "isLiquidBlocking", - "parameters": [ - { - "name": "liquidType", - "optional": false, - "type": "LiquidType", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "liquidCanFlowFromDirection", - "parameters": [ - { - "name": "liquidType", - "optional": false, - "type": "LiquidType", - "rest": false - }, - { - "name": "flowDirection", - "optional": false, - "type": "Direction", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "liquidSpreadCausesSpawn", - "parameters": [ - { - "name": "liquidType", - "optional": false, - "type": "LiquidType", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "matches", - "parameters": [ - { - "name": "blockName", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "states", - "optional": true, - "type": "Record", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "north", - "parameters": [ - { - "name": "steps", - "optional": true, - "type": "number", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "offset", - "parameters": [ - { - "name": "offset", - "optional": false, - "type": "Vector3", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "setPermutation", - "parameters": [ - { - "name": "permutation", - "optional": false, - "type": "BlockPermutation", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setType", - "parameters": [ - { - "name": "blockType", - "optional": false, - "type": "BlockType | string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setWaterlogged", - "parameters": [ - { - "name": "isWaterlogged", - "optional": false, - "type": "boolean", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "south", - "parameters": [ - { - "name": "steps", - "optional": true, - "type": "number", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "trySetPermutation", - "parameters": [ - { - "name": "permutation", - "optional": false, - "type": "BlockPermutation", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "west", - "parameters": [ - { - "name": "steps", - "optional": true, - "type": "number", - "rest": false - } - ], - "impl": "l0" - } - ], - "kind": "object" - }, - "Dimension": { - "properties": [ - { - "name": "heightRange", - "readonly": true, - "type": "minecraftcommon.NumberRange", - "impl": "l2" - }, - { - "name": "id", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "localizationKey", - "readonly": true, - "type": "string", - "impl": "l2" - } - ], - "methods": [ - { - "name": "calculateClosestBiomeFromSeed", - "parameters": [ - { - "name": "pos", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "biomeToFind", - "optional": false, - "type": "BiomeType | string", - "rest": false - }, - { - "name": "options", - "optional": true, - "type": "BiomeSearchOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "cloneBlocks", - "parameters": [ - { - "name": "beginLocation", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "endLocation", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "destination", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "cloneMode", - "optional": false, - "type": "CloneMode", - "rest": false - }, - { - "name": "filter", - "optional": true, - "type": "BlockFilter", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "containsBiomes", - "parameters": [ - { - "name": "volume", - "optional": false, - "type": "BlockVolumeBase", - "rest": false - }, - { - "name": "biomeFilter", - "optional": false, - "type": "BiomeFilter", - "rest": false - }, - { - "name": "isSuperset", - "optional": false, - "type": "boolean", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "containsBlock", - "parameters": [ - { - "name": "volume", - "optional": false, - "type": "BlockVolumeBase", - "rest": false - }, - { - "name": "filter", - "optional": false, - "type": "BlockFilter", - "rest": false - }, - { - "name": "allowUnloadedChunks", - "optional": true, - "type": "boolean", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "createExplosion", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "radius", - "optional": false, - "type": "number", - "rest": false - }, - { - "name": "explosionOptions", - "optional": true, - "type": "ExplosionOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "fillBlocks", - "parameters": [ - { - "name": "volume", - "optional": false, - "type": "BlockVolumeBase", - "rest": false - }, - { - "name": "block", - "optional": false, - "type": "BlockPermutation | BlockType | string", - "rest": false - }, - { - "name": "options", - "optional": true, - "type": "BlockFillOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getBiome", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getBlock", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getBlockAbove", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "options", - "optional": true, - "type": "BlockRaycastOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getBlockBelow", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "options", - "optional": true, - "type": "BlockRaycastOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getBlockFromRay", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "direction", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "options", - "optional": true, - "type": "BlockRaycastOptions", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getBlocks", - "parameters": [ - { - "name": "volume", - "optional": false, - "type": "BlockVolumeBase", - "rest": false - }, - { - "name": "options", - "optional": false, - "type": "BlockQueryOptions", - "rest": false - }, - { - "name": "allowUnloadedChunks", - "optional": true, - "type": "boolean", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getEntities", - "parameters": [ - { - "name": "options", - "optional": true, - "type": "EntityQueryOptions", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getEntitiesAtBlockLocation", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getEntitiesFromRay", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "direction", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "options", - "optional": true, - "type": "EntityRaycastOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getGeneratedStructures", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getLightLevel", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getPlayers", - "parameters": [ - { - "name": "options", - "optional": true, - "type": "EntityQueryOptions", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getSkyLightLevel", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getTopmostBlock", - "parameters": [ - { - "name": "locationXZ", - "optional": false, - "type": "VectorXZ", - "rest": false - }, - { - "name": "minHeight", - "optional": true, - "type": "number", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getWeather", - "parameters": [], - "impl": "l2" - }, - { - "name": "isChunkLoaded", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "placeFeature", - "parameters": [ - { - "name": "featureName", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "shouldThrow", - "optional": true, - "type": "boolean", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "placeFeatureRule", - "parameters": [ - { - "name": "featureRuleName", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "playSound", - "parameters": [ - { - "name": "soundId", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "soundOptions", - "optional": true, - "type": "WorldSoundOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "runCommand", - "parameters": [ - { - "name": "commandString", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setBlockPermutation", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "permutation", - "optional": false, - "type": "BlockPermutation", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setBlockType", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "blockType", - "optional": false, - "type": "BlockType | string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setWeather", - "parameters": [ - { - "name": "weatherType", - "optional": false, - "type": "WeatherType", - "rest": false - }, - { - "name": "duration", - "optional": true, - "type": "number", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "spawnEntity", - "parameters": [ - { - "name": "identifier", - "optional": false, - "type": "EntityIdentifierType>", - "rest": false - }, - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "options", - "optional": true, - "type": "SpawnEntityOptions", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "spawnItem", - "parameters": [ - { - "name": "itemStack", - "optional": false, - "type": "ItemStack", - "rest": false - }, - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "spawnParticle", - "parameters": [ - { - "name": "effectName", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "molangVariables", - "optional": true, - "type": "MolangVariableMap", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "spawnXp", - "parameters": [ - { - "name": "location", - "optional": false, - "type": "Vector3", - "rest": false - }, - { - "name": "amount", - "optional": false, - "type": "number", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "stopAllSounds", - "parameters": [], - "impl": "l0" - }, - { - "name": "stopSound", - "parameters": [ - { - "name": "soundId", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - } - ], - "kind": "object" - }, - "World": { - "properties": [ - { - "name": "afterEvents", - "readonly": true, - "type": "WorldAfterEvents", - "impl": "l2" - }, - { - "name": "allowCheats", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "beforeEvents", - "readonly": true, - "type": "WorldBeforeEvents", - "impl": "l2" - }, - { - "name": "gameRules", - "readonly": true, - "type": "GameRules", - "impl": "l0" - }, - { - "name": "isHardcore", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "primitiveShapesManager", - "readonly": true, - "type": "PrimitiveShapesManager", - "impl": "l0" - }, - { - "name": "scoreboard", - "readonly": true, - "type": "Scoreboard", - "impl": "l2" - }, - { - "name": "seed", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "soundDefinitionRegistry", - "readonly": true, - "type": "SoundDefinitionRegistry", - "impl": "l0" - }, - { - "name": "structureManager", - "readonly": true, - "type": "StructureManager", - "impl": "l0" - }, - { - "name": "tickingAreaManager", - "readonly": true, - "type": "TickingAreaManager", - "impl": "l0" - } - ], - "methods": [ - { - "name": "broadcastClientMessage", - "parameters": [ - { - "name": "id", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "value", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "clearDynamicProperties", - "parameters": [], - "impl": "l2" - }, - { - "name": "getAbsoluteTime", - "parameters": [], - "impl": "l2" - }, - { - "name": "getAimAssist", - "parameters": [], - "impl": "l0" - }, - { - "name": "getAllPlayers", - "parameters": [], - "impl": "l2" - }, - { - "name": "getDay", - "parameters": [], - "impl": "l2" - }, - { - "name": "getDefaultSpawnLocation", - "parameters": [], - "impl": "l2" - }, - { - "name": "getDifficulty", - "parameters": [], - "impl": "l0" - }, - { - "name": "getDimension", - "parameters": [ - { - "name": "dimensionId", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getDynamicProperty", - "parameters": [ - { - "name": "identifier", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getDynamicPropertyIds", - "parameters": [], - "impl": "l2" - }, - { - "name": "getDynamicPropertyTotalByteCount", - "parameters": [], - "impl": "l2" - }, - { - "name": "getEntity", - "parameters": [ - { - "name": "id", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getLootTableManager", - "parameters": [], - "impl": "l0" - }, - { - "name": "getMoonPhase", - "parameters": [], - "impl": "l0" - }, - { - "name": "getPackSettings", - "parameters": [], - "impl": "l0" - }, - { - "name": "getPlayers", - "parameters": [ - { - "name": "options", - "optional": true, - "type": "EntityQueryOptions", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getTimeOfDay", - "parameters": [], - "impl": "l2" - }, - { - "name": "playMusic", - "parameters": [ - { - "name": "trackId", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "musicOptions", - "optional": true, - "type": "MusicOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "queueMusic", - "parameters": [ - { - "name": "trackId", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "musicOptions", - "optional": true, - "type": "MusicOptions", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "sendMessage", - "parameters": [ - { - "name": "message", - "optional": false, - "type": "(RawMessage | string)[] | RawMessage | string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setAbsoluteTime", - "parameters": [ - { - "name": "absoluteTime", - "optional": false, - "type": "number", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setDefaultSpawnLocation", - "parameters": [ - { - "name": "spawnLocation", - "optional": false, - "type": "Vector3", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setDifficulty", - "parameters": [ - { - "name": "difficulty", - "optional": false, - "type": "Difficulty", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "setDynamicProperties", - "parameters": [ - { - "name": "values", - "optional": false, - "type": "Record", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setDynamicProperty", - "parameters": [ - { - "name": "identifier", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "value", - "optional": true, - "type": "boolean | number | string | Vector3", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setTimeOfDay", - "parameters": [ - { - "name": "timeOfDay", - "optional": false, - "type": "number | TimeOfDay", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "stopMusic", - "parameters": [], - "impl": "l0" - } - ], - "kind": "object" - }, - "System": { - "properties": [ - { - "name": "afterEvents", - "readonly": true, - "type": "SystemAfterEvents", - "impl": "l2" - }, - { - "name": "beforeEvents", - "readonly": true, - "type": "SystemBeforeEvents", - "impl": "l2" - }, - { - "name": "currentTick", - "readonly": true, - "type": "number", - "impl": "l2" - }, - { - "name": "isEditorWorld", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "serverSystemInfo", - "readonly": true, - "type": "SystemInfo", - "impl": "l0" - } - ], - "methods": [ - { - "name": "clearJob", - "parameters": [ - { - "name": "jobId", - "optional": false, - "type": "number", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "clearRun", - "parameters": [ - { - "name": "runId", - "optional": false, - "type": "number", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "run", - "parameters": [ - { - "name": "callback", - "optional": false, - "type": "() => void", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "runInterval", - "parameters": [ - { - "name": "callback", - "optional": false, - "type": "() => void", - "rest": false - }, - { - "name": "tickInterval", - "optional": true, - "type": "number", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "runJob", - "parameters": [ - { - "name": "generator", - "optional": false, - "type": "Generator", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "runTimeout", - "parameters": [ - { - "name": "callback", - "optional": false, - "type": "() => void", - "rest": false - }, - { - "name": "tickDelay", - "optional": true, - "type": "number", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "sendScriptEvent", - "parameters": [ - { - "name": "id", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "message", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "waitTicks", - "parameters": [ - { - "name": "ticks", - "optional": false, - "type": "number", - "rest": false - } - ], - "impl": "l2" - } - ], - "kind": "object" - }, - "Container": { - "properties": [ - { - "name": "containerRules", - "readonly": true, - "type": "ContainerRules", - "impl": "l0" - }, - { - "name": "emptySlotsCount", - "readonly": true, - "type": "number", - "impl": "l2" - }, - { - "name": "isValid", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "size", - "readonly": true, - "type": "number", - "impl": "l2" - }, - { - "name": "weight", - "readonly": true, - "type": "number", - "impl": "l2" - } - ], - "methods": [ - { - "name": "addItem", - "parameters": [ - { - "name": "itemStack", - "optional": false, - "type": "ItemStack", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "clearAll", - "parameters": [], - "impl": "l2" - }, - { - "name": "contains", - "parameters": [ - { - "name": "itemStack", - "optional": false, - "type": "ItemStack", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "find", - "parameters": [ - { - "name": "itemStack", - "optional": false, - "type": "ItemStack", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "findLast", - "parameters": [ - { - "name": "itemStack", - "optional": false, - "type": "ItemStack", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "firstEmptySlot", - "parameters": [], - "impl": "l0" - }, - { - "name": "firstItem", - "parameters": [], - "impl": "l0" - }, - { - "name": "getItem", - "parameters": [ - { - "name": "slot", - "optional": false, - "type": "number", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getSlot", - "parameters": [ - { - "name": "slot", - "optional": false, - "type": "number", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "moveItem", - "parameters": [ - { - "name": "fromSlot", - "optional": false, - "type": "number", - "rest": false - }, - { - "name": "toSlot", - "optional": false, - "type": "number", - "rest": false - }, - { - "name": "toContainer", - "optional": false, - "type": "Container", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setItem", - "parameters": [ - { - "name": "slot", - "optional": false, - "type": "number", - "rest": false - }, - { - "name": "itemStack", - "optional": true, - "type": "ItemStack", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "swapItems", - "parameters": [ - { - "name": "slot", - "optional": false, - "type": "number", - "rest": false - }, - { - "name": "otherSlot", - "optional": false, - "type": "number", - "rest": false - }, - { - "name": "otherContainer", - "optional": false, - "type": "Container", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "transferItem", - "parameters": [ - { - "name": "fromSlot", - "optional": false, - "type": "number", - "rest": false - }, - { - "name": "toContainer", - "optional": false, - "type": "Container", - "rest": false - } - ], - "impl": "l2" - } - ], - "kind": "object" - }, - "BlockPermutation": { - "properties": [ - { - "name": "localizationKey", - "readonly": true, - "type": "string", - "impl": "l2" - } - ], - "methods": [ - { - "name": "canBeDestroyedByLiquidSpread", - "parameters": [ - { - "name": "liquidType", - "optional": false, - "type": "LiquidType", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "canContainLiquid", - "parameters": [ - { - "name": "liquidType", - "optional": false, - "type": "LiquidType", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "esolve", - "parameters": [ - { - "name": "blockName", - "optional": false, - "type": "T", - "rest": false - }, - { - "name": "states", - "optional": true, - "type": "BlockStateArg", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getAllStates", - "parameters": [], - "impl": "l2" - }, - { - "name": "getItemStack", - "parameters": [ - { - "name": "amount", - "optional": true, - "type": "number", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "getState", - "parameters": [ - { - "name": "stateName", - "optional": false, - "type": "T", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getTags", - "parameters": [], - "impl": "l2" - }, - { - "name": "hasTag", - "parameters": [ - { - "name": "tag", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "isLiquidBlocking", - "parameters": [ - { - "name": "liquidType", - "optional": false, - "type": "LiquidType", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "liquidSpreadCausesSpawn", - "parameters": [ - { - "name": "liquidType", - "optional": false, - "type": "LiquidType", - "rest": false - } - ], - "impl": "l0" - }, - { - "name": "matches", - "parameters": [ - { - "name": "blockName", - "optional": false, - "type": "T", - "rest": false - }, - { - "name": "states", - "optional": true, - "type": "BlockStateArg", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "withState", - "parameters": [ - { - "name": "name", - "optional": false, - "type": "T", - "rest": false - }, - { - "name": "value", - "optional": false, - "type": "minecraftvanilladata.BlockStateSuperset[T]", - "rest": false - } - ], - "impl": "l2" - } - ], - "kind": "object" - }, - "Scoreboard": { - "properties": [], - "methods": [ - { - "name": "addObjective", - "parameters": [ - { - "name": "objectiveId", - "optional": false, - "type": "string", - "rest": false - }, - { - "name": "displayName", - "optional": true, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "clearObjectiveAtDisplaySlot", - "parameters": [ - { - "name": "displaySlotId", - "optional": false, - "type": "DisplaySlotId", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getObjective", - "parameters": [ - { - "name": "objectiveId", - "optional": false, - "type": "string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getObjectiveAtDisplaySlot", - "parameters": [ - { - "name": "displaySlotId", - "optional": false, - "type": "DisplaySlotId", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getObjectives", - "parameters": [], - "impl": "l2" - }, - { - "name": "getParticipants", - "parameters": [], - "impl": "l2" - }, - { - "name": "removeObjective", - "parameters": [ - { - "name": "objectiveId", - "optional": false, - "type": "ScoreboardObjective | string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setObjectiveAtDisplaySlot", - "parameters": [ - { - "name": "displaySlotId", - "optional": false, - "type": "DisplaySlotId", - "rest": false - }, - { - "name": "objectiveDisplaySetting", - "optional": false, - "type": "ScoreboardObjectiveDisplayOptions", - "rest": false - } - ], - "impl": "l2" - } - ], - "kind": "object" - }, - "ScoreboardObjective": { - "properties": [ - { - "name": "displayName", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "id", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "isValid", - "readonly": true, - "type": "boolean", - "impl": "l2" - } - ], - "methods": [ - { - "name": "addScore", - "parameters": [ - { - "name": "participant", - "optional": false, - "type": "Entity | ScoreboardIdentity | string", - "rest": false - }, - { - "name": "scoreToAdd", - "optional": false, - "type": "number", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getParticipants", - "parameters": [], - "impl": "l2" - }, - { - "name": "getScore", - "parameters": [ - { - "name": "participant", - "optional": false, - "type": "Entity | ScoreboardIdentity | string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "getScores", - "parameters": [], - "impl": "l2" - }, - { - "name": "hasParticipant", - "parameters": [ - { - "name": "participant", - "optional": false, - "type": "Entity | ScoreboardIdentity | string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "removeParticipant", - "parameters": [ - { - "name": "participant", - "optional": false, - "type": "Entity | ScoreboardIdentity | string", - "rest": false - } - ], - "impl": "l2" - }, - { - "name": "setScore", - "parameters": [ - { - "name": "participant", - "optional": false, - "type": "Entity | ScoreboardIdentity | string", - "rest": false - }, - { - "name": "score", - "optional": false, - "type": "number", - "rest": false - } - ], - "impl": "l2" - } - ], - "kind": "object" - }, - "ShutdownEvent": { - "properties": [], - "methods": [], - "kind": "event" - }, - "StartupEvent": { - "properties": [ - { - "name": "blockComponentRegistry", - "readonly": true, - "type": "BlockComponentRegistry", - "impl": "l2" - }, - { - "name": "customCommandRegistry", - "readonly": true, - "type": "CustomCommandRegistry", - "impl": "l2" - }, - { - "name": "dimensionRegistry", - "readonly": true, - "type": "DimensionRegistry", - "impl": "l2" - }, - { - "name": "itemComponentRegistry", - "readonly": true, - "type": "ItemComponentRegistry", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "WatchdogTerminateBeforeEvent": { - "properties": [ - { - "name": "cancel", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "terminateReason", - "readonly": true, - "type": "WatchdogTerminateReason", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "ScriptEventCommandMessageAfterEvent": { - "properties": [ - { - "name": "id", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "initiator", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "message", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "sourceBlock", - "readonly": true, - "type": "Block", - "impl": "l2" - }, - { - "name": "sourceEntity", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "sourceType", - "readonly": true, - "type": "ScriptEventSource", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "ChatSendBeforeEvent": { - "properties": [ - { - "name": "cancel", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "message", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "sender", - "readonly": true, - "type": "Player", - "impl": "l2" - }, - { - "name": "targets", - "readonly": true, - "type": "Player[]", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EffectAddBeforeEvent": { - "properties": [ - { - "name": "cancel", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "duration", - "readonly": false, - "type": "number", - "impl": "l2" - }, - { - "name": "effectType", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "entity", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityHealBeforeEvent": { - "properties": [ - { - "name": "cancel", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "healedEntity", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "healing", - "readonly": false, - "type": "number", - "impl": "l2" - }, - { - "name": "healSource", - "readonly": true, - "type": "EntityHealSource", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityHurtBeforeEvent": { - "properties": [ - { - "name": "cancel", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "damage", - "readonly": false, - "type": "number", - "impl": "l2" - }, - { - "name": "damageSource", - "readonly": true, - "type": "EntityDamageSource", - "impl": "l2" - }, - { - "name": "hurtEntity", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityItemPickupBeforeEvent": { - "properties": [ - { - "name": "cancel", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "entity", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "item", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityRemoveBeforeEvent": { - "properties": [ - { - "name": "removedEntity", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityTamedBeforeEvent": { - "properties": [ - { - "name": "cancel", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "entity", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "tamingEntity", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "ExplosionBeforeEvent": { - "properties": [ - { - "name": "cancel", - "readonly": false, - "type": "boolean", - "impl": "l2" - } - ], - "methods": [ - { - "name": "setImpactedBlocks", - "parameters": [ - { - "name": "blocks", - "optional": false, - "type": "Block[]", - "rest": false - } - ], - "impl": "l0" - } - ], - "kind": "event" - }, - "ItemUseBeforeEvent": { - "properties": [ - { - "name": "cancel", - "readonly": false, - "type": "boolean", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerBreakBlockBeforeEvent": { - "properties": [ - { - "name": "cancel", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "itemStack", - "readonly": false, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerGameModeChangeBeforeEvent": { - "properties": [ - { - "name": "cancel", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "fromGameMode", - "readonly": true, - "type": "GameMode", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - }, - { - "name": "toGameMode", - "readonly": false, - "type": "GameMode", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerInteractWithBlockBeforeEvent": { - "properties": [ - { - "name": "block", - "readonly": true, - "type": "Block", - "impl": "l2" - }, - { - "name": "blockFace", - "readonly": true, - "type": "Direction", - "impl": "l2" - }, - { - "name": "cancel", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "faceLocation", - "readonly": true, - "type": "Vector3", - "impl": "l2" - }, - { - "name": "isFirstEvent", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "itemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerInteractWithEntityBeforeEvent": { - "properties": [ - { - "name": "cancel", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "itemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - }, - { - "name": "target", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerLeaveBeforeEvent": { - "properties": [ - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerPlaceBlockBeforeEvent": { - "properties": [ - { - "name": "cancel", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "face", - "readonly": true, - "type": "Direction", - "impl": "l2" - }, - { - "name": "faceLocation", - "readonly": true, - "type": "Vector3", - "impl": "l2" - }, - { - "name": "permutationToPlace", - "readonly": true, - "type": "BlockPermutation", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "WeatherChangeBeforeEvent": { - "properties": [ - { - "name": "cancel", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "duration", - "readonly": false, - "type": "number", - "impl": "l2" - }, - { - "name": "newWeather", - "readonly": false, - "type": "WeatherType", - "impl": "l2" - }, - { - "name": "previousWeather", - "readonly": true, - "type": "WeatherType", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "BlockContainerClosedAfterEvent": { - "properties": [ - { - "name": "closeSource", - "readonly": false, - "type": "ContainerAccessSource", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "BlockContainerOpenedAfterEvent": { - "properties": [ - { - "name": "openSource", - "readonly": false, - "type": "ContainerAccessSource", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "BlockExplodeAfterEvent": { - "properties": [ - { - "name": "explodedBlockPermutation", - "readonly": true, - "type": "BlockPermutation", - "impl": "l2" - }, - { - "name": "source", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "ButtonPushAfterEvent": { - "properties": [ - { - "name": "source", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "ChatSendAfterEvent": { - "properties": [ - { - "name": "message", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "sender", - "readonly": true, - "type": "Player", - "impl": "l2" - }, - { - "name": "targets", - "readonly": true, - "type": "Player[]", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "DataDrivenEntityTriggerAfterEvent": { - "properties": [ - { - "name": "entity", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "eventId", - "readonly": true, - "type": "string", - "impl": "l2" - } - ], - "methods": [ - { - "name": "getModifiers", - "parameters": [], - "impl": "l0" - } - ], - "kind": "event" - }, - "EffectAddAfterEvent": { - "properties": [ - { - "name": "effect", - "readonly": true, - "type": "Effect", - "impl": "l2" - }, - { - "name": "entity", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityContainerClosedAfterEvent": { - "properties": [ - { - "name": "closeSource", - "readonly": true, - "type": "ContainerAccessSource", - "impl": "l2" - }, - { - "name": "entity", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityContainerOpenedAfterEvent": { - "properties": [ - { - "name": "entity", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "openSource", - "readonly": true, - "type": "ContainerAccessSource", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityDieAfterEvent": { - "properties": [ - { - "name": "damageSource", - "readonly": true, - "type": "EntityDamageSource", - "impl": "l2" - }, - { - "name": "deadEntity", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityHealAfterEvent": { - "properties": [ - { - "name": "healedEntity", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "healing", - "readonly": true, - "type": "number", - "impl": "l2" - }, - { - "name": "healSource", - "readonly": true, - "type": "EntityHealSource", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityHealthChangedAfterEvent": { - "properties": [ - { - "name": "entity", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "newValue", - "readonly": true, - "type": "number", - "impl": "l2" - }, - { - "name": "oldValue", - "readonly": true, - "type": "number", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityHitBlockAfterEvent": { - "properties": [ - { - "name": "blockFace", - "readonly": true, - "type": "Direction", - "impl": "l2" - }, - { - "name": "damagingEntity", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "hitBlock", - "readonly": true, - "type": "Block", - "impl": "l2" - }, - { - "name": "hitBlockPermutation", - "readonly": true, - "type": "BlockPermutation", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityHitEntityAfterEvent": { - "properties": [ - { - "name": "damagingEntity", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "hitEntity", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityHurtAfterEvent": { - "properties": [ - { - "name": "damage", - "readonly": true, - "type": "number", - "impl": "l2" - }, - { - "name": "damageSource", - "readonly": true, - "type": "EntityDamageSource", - "impl": "l2" - }, - { - "name": "hurtEntity", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityItemDropAfterEvent": { - "properties": [ - { - "name": "entity", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "items", - "readonly": true, - "type": "Entity[]", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityItemPickupAfterEvent": { - "properties": [ - { - "name": "entity", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "items", - "readonly": true, - "type": "ItemStack[]", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityLoadAfterEvent": { - "properties": [ - { - "name": "entity", - "readonly": false, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityRemoveAfterEvent": { - "properties": [ - { - "name": "removedEntityId", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "typeId", - "readonly": true, - "type": "string", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntitySpawnAfterEvent": { - "properties": [ - { - "name": "cause", - "readonly": true, - "type": "EntityInitializationCause", - "impl": "l2" - }, - { - "name": "entity", - "readonly": false, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityStartSneakingAfterEvent": { - "properties": [ - { - "name": "entity", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityStopSneakingAfterEvent": { - "properties": [ - { - "name": "entity", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityTamedAfterEvent": { - "properties": [ - { - "name": "entity", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "tamingEntity", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "EntityUpgradeAfterEvent": { - "properties": [ - { - "name": "entity", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "upgradeId", - "readonly": true, - "type": "string", - "impl": "l2" - } - ], - "methods": [ - { - "name": "getModifiers", - "parameters": [], - "impl": "l0" - } - ], - "kind": "event" - }, - "ExplosionAfterEvent": { - "properties": [ - { - "name": "dimension", - "readonly": true, - "type": "Dimension", - "impl": "l2" - }, - { - "name": "source", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [ - { - "name": "getImpactedBlocks", - "parameters": [], - "impl": "l0" - } - ], - "kind": "event" - }, - "GameRuleChangeAfterEvent": { - "properties": [ - { - "name": "rule", - "readonly": true, - "type": "GameRule", - "impl": "l2" - }, - { - "name": "value", - "readonly": true, - "type": "boolean | number", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "ItemCompleteUseAfterEvent": { - "properties": [ - { - "name": "itemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "source", - "readonly": true, - "type": "Player", - "impl": "l2" - }, - { - "name": "useDuration", - "readonly": true, - "type": "number", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "ItemReleaseUseAfterEvent": { - "properties": [ - { - "name": "itemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "source", - "readonly": true, - "type": "Player", - "impl": "l2" - }, - { - "name": "useDuration", - "readonly": true, - "type": "number", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "ItemStartUseAfterEvent": { - "properties": [ - { - "name": "itemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "source", - "readonly": true, - "type": "Player", - "impl": "l2" - }, - { - "name": "useDuration", - "readonly": true, - "type": "number", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "ItemStartUseOnAfterEvent": { - "properties": [ - { - "name": "block", - "readonly": true, - "type": "Block", - "impl": "l2" - }, - { - "name": "blockFace", - "readonly": true, - "type": "Direction", - "impl": "l2" - }, - { - "name": "itemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "source", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "ItemStopUseAfterEvent": { - "properties": [ - { - "name": "itemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "source", - "readonly": true, - "type": "Player", - "impl": "l2" - }, - { - "name": "useDuration", - "readonly": true, - "type": "number", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "ItemStopUseOnAfterEvent": { - "properties": [ - { - "name": "block", - "readonly": true, - "type": "Block", - "impl": "l2" - }, - { - "name": "itemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "source", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "ItemUseAfterEvent": { - "properties": [ - { - "name": "itemStack", - "readonly": false, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "source", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "LeverActionAfterEvent": { - "properties": [ - { - "name": "isPowered", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "MessageReceiveAfterEvent": { - "properties": [ - { - "name": "id", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "message", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PackSettingChangeAfterEvent": { - "properties": [ - { - "name": "settingName", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "settingValue", - "readonly": true, - "type": "boolean | number | string", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PistonActivateAfterEvent": { - "properties": [ - { - "name": "isExpanding", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "piston", - "readonly": true, - "type": "BlockPistonComponent", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerBreakBlockAfterEvent": { - "properties": [ - { - "name": "brokenBlockPermutation", - "readonly": true, - "type": "BlockPermutation", - "impl": "l2" - }, - { - "name": "itemStackAfterBreak", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "itemStackBeforeBreak", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerButtonInputAfterEvent": { - "properties": [ - { - "name": "button", - "readonly": true, - "type": "InputButton", - "impl": "l2" - }, - { - "name": "newButtonState", - "readonly": true, - "type": "ButtonState", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerCancelBreakingBlockAfterEvent": { - "properties": [ - { - "name": "blockPermutation", - "readonly": true, - "type": "BlockPermutation", - "impl": "l2" - }, - { - "name": "breakProgress", - "readonly": true, - "type": "number", - "impl": "l2" - }, - { - "name": "face", - "readonly": true, - "type": "Direction", - "impl": "l2" - }, - { - "name": "heldItemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerDimensionChangeAfterEvent": { - "properties": [ - { - "name": "fromDimension", - "readonly": true, - "type": "Dimension", - "impl": "l2" - }, - { - "name": "fromLocation", - "readonly": true, - "type": "Vector3", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - }, - { - "name": "toDimension", - "readonly": true, - "type": "Dimension", - "impl": "l2" - }, - { - "name": "toLocation", - "readonly": true, - "type": "Vector3", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerEmoteAfterEvent": { - "properties": [ - { - "name": "personaPieceId", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerGameModeChangeAfterEvent": { - "properties": [ - { - "name": "fromGameMode", - "readonly": true, - "type": "GameMode", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - }, - { - "name": "toGameMode", - "readonly": true, - "type": "GameMode", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerHotbarSelectedSlotChangeAfterEvent": { - "properties": [ - { - "name": "itemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "newSlotSelected", - "readonly": true, - "type": "number", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - }, - { - "name": "previousSlotSelected", - "readonly": true, - "type": "number", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerInputModeChangeAfterEvent": { - "properties": [ - { - "name": "newInputModeUsed", - "readonly": true, - "type": "InputMode", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - }, - { - "name": "previousInputModeUsed", - "readonly": true, - "type": "InputMode", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerInputPermissionCategoryChangeAfterEvent": { - "properties": [ - { - "name": "category", - "readonly": true, - "type": "InputPermissionCategory", - "impl": "l2" - }, - { - "name": "enabled", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerInteractWithBlockAfterEvent": { - "properties": [ - { - "name": "beforeItemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "block", - "readonly": true, - "type": "Block", - "impl": "l2" - }, - { - "name": "blockFace", - "readonly": true, - "type": "Direction", - "impl": "l2" - }, - { - "name": "faceLocation", - "readonly": true, - "type": "Vector3", - "impl": "l2" - }, - { - "name": "isFirstEvent", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "itemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerInteractWithEntityAfterEvent": { - "properties": [ - { - "name": "beforeItemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "itemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - }, - { - "name": "target", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerInventoryItemChangeAfterEvent": { - "properties": [ - { - "name": "beforeItemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "inventoryType", - "readonly": true, - "type": "PlayerInventoryType", - "impl": "l2" - }, - { - "name": "itemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - }, - { - "name": "slot", - "readonly": true, - "type": "number", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerJoinAfterEvent": { - "properties": [ - { - "name": "playerId", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "playerName", - "readonly": true, - "type": "string", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerLeaveAfterEvent": { - "properties": [ - { - "name": "playerId", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "playerName", - "readonly": true, - "type": "string", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerPlaceBlockAfterEvent": { - "properties": [ - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerSpawnAfterEvent": { - "properties": [ - { - "name": "initialSpawn", - "readonly": false, - "type": "boolean", - "impl": "l2" - }, - { - "name": "player", - "readonly": false, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerStartBreakingBlockAfterEvent": { - "properties": [ - { - "name": "blockPermutation", - "readonly": true, - "type": "BlockPermutation", - "impl": "l2" - }, - { - "name": "face", - "readonly": true, - "type": "Direction", - "impl": "l2" - }, - { - "name": "heldItemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerSwingStartAfterEvent": { - "properties": [ - { - "name": "heldItemStack", - "readonly": true, - "type": "ItemStack", - "impl": "l2" - }, - { - "name": "player", - "readonly": true, - "type": "Player", - "impl": "l2" - }, - { - "name": "swingSource", - "readonly": true, - "type": "EntitySwingSource", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PlayerUseNameTagAfterEvent": { - "properties": [ - { - "name": "entityNamed", - "readonly": false, - "type": "Entity", - "impl": "l2" - }, - { - "name": "newName", - "readonly": false, - "type": "string", - "impl": "l2" - }, - { - "name": "player", - "readonly": false, - "type": "Player", - "impl": "l2" - }, - { - "name": "previousName", - "readonly": false, - "type": "string", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PressurePlatePopAfterEvent": { - "properties": [ - { - "name": "previousRedstonePower", - "readonly": true, - "type": "number", - "impl": "l2" - }, - { - "name": "redstonePower", - "readonly": true, - "type": "number", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "PressurePlatePushAfterEvent": { - "properties": [ - { - "name": "previousRedstonePower", - "readonly": true, - "type": "number", - "impl": "l2" - }, - { - "name": "redstonePower", - "readonly": true, - "type": "number", - "impl": "l2" - }, - { - "name": "source", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "ProjectileHitBlockAfterEvent": { - "properties": [ - { - "name": "dimension", - "readonly": true, - "type": "Dimension", - "impl": "l2" - }, - { - "name": "hitVector", - "readonly": true, - "type": "Vector3", - "impl": "l2" - }, - { - "name": "location", - "readonly": true, - "type": "Vector3", - "impl": "l2" - }, - { - "name": "projectile", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "source", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [ - { - "name": "getBlockHit", - "parameters": [], - "impl": "l0" - } - ], - "kind": "event" - }, - "ProjectileHitEntityAfterEvent": { - "properties": [ - { - "name": "dimension", - "readonly": true, - "type": "Dimension", - "impl": "l2" - }, - { - "name": "hitVector", - "readonly": true, - "type": "Vector3", - "impl": "l2" - }, - { - "name": "location", - "readonly": true, - "type": "Vector3", - "impl": "l2" - }, - { - "name": "projectile", - "readonly": true, - "type": "Entity", - "impl": "l2" - }, - { - "name": "source", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [ - { - "name": "getEntityHit", - "parameters": [], - "impl": "l0" - } - ], - "kind": "event" - }, - "SoundCompletedAfterEvent": { - "properties": [ - { - "name": "soundInstanceId", - "readonly": true, - "type": "string", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "TargetBlockHitAfterEvent": { - "properties": [ - { - "name": "hitVector", - "readonly": true, - "type": "Vector3", - "impl": "l2" - }, - { - "name": "previousRedstonePower", - "readonly": true, - "type": "number", - "impl": "l2" - }, - { - "name": "redstonePower", - "readonly": true, - "type": "number", - "impl": "l2" - }, - { - "name": "source", - "readonly": true, - "type": "Entity", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "TripWireTripAfterEvent": { - "properties": [ - { - "name": "isPowered", - "readonly": true, - "type": "boolean", - "impl": "l2" - }, - { - "name": "sources", - "readonly": true, - "type": "Entity[]", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "WeatherChangeAfterEvent": { - "properties": [ - { - "name": "dimension", - "readonly": true, - "type": "string", - "impl": "l2" - }, - { - "name": "newWeather", - "readonly": true, - "type": "WeatherType", - "impl": "l2" - }, - { - "name": "previousWeather", - "readonly": true, - "type": "WeatherType", - "impl": "l2" - } - ], - "methods": [], - "kind": "event" - }, - "WorldLoadAfterEvent": { - "properties": [], - "methods": [], - "kind": "event" - } - }, - "events": { - "system.beforeEvents": [ - "shutdown", - "startup", - "watchdogTerminate" - ], - "system.afterEvents": [ - "scriptEventReceive" - ], - "world.beforeEvents": [ - "chatSend", - "effectAdd", - "entityHeal", - "entityHurt", - "entityItemPickup", - "entityRemove", - "entityTamed", - "explosion", - "itemUse", - "playerBreakBlock", - "playerGameModeChange", - "playerInteractWithBlock", - "playerInteractWithEntity", - "playerLeave", - "playerPlaceBlock", - "weatherChange" - ], - "world.afterEvents": [ - "blockContainerClosed", - "blockContainerOpened", - "blockExplode", - "buttonPush", - "chatSend", - "dataDrivenEntityTrigger", - "effectAdd", - "entityContainerClosed", - "entityContainerOpened", - "entityDie", - "entityHeal", - "entityHealthChanged", - "entityHitBlock", - "entityHitEntity", - "entityHurt", - "entityItemDrop", - "entityItemPickup", - "entityLoad", - "entityRemove", - "entitySpawn", - "entityStartSneaking", - "entityStopSneaking", - "entityTamed", - "entityUpgrade", - "explosion", - "gameRuleChange", - "itemCompleteUse", - "itemReleaseUse", - "itemStartUse", - "itemStartUseOn", - "itemStopUse", - "itemStopUseOn", - "itemUse", - "leverAction", - "messageReceive", - "packSettingChange", - "pistonActivate", - "playerBreakBlock", - "playerButtonInput", - "playerCancelBreakingBlock", - "playerDimensionChange", - "playerEmote", - "playerGameModeChange", - "playerHotbarSelectedSlotChange", - "playerInputModeChange", - "playerInputPermissionCategoryChange", - "playerInteractWithBlock", - "playerInteractWithEntity", - "playerInventoryItemChange", - "playerJoin", - "playerLeave", - "playerPlaceBlock", - "playerSpawn", - "playerStartBreakingBlock", - "playerSwingStart", - "playerUseNameTag", - "pressurePlatePop", - "pressurePlatePush", - "projectileHitBlock", - "projectileHitEntity", - "soundCompleted", - "targetBlockHit", - "tripWireTrip", - "weatherChange", - "worldLoad" - ] - }, - "eventTypes": { - "system.beforeEvents.shutdown": { - "eventType": "ShutdownEvent", - "signalType": "ShutdownBeforeEventSignal" - }, - "system.beforeEvents.startup": { - "eventType": "StartupEvent", - "signalType": "StartupBeforeEventSignal" - }, - "system.beforeEvents.watchdogTerminate": { - "eventType": "WatchdogTerminateBeforeEvent", - "signalType": "WatchdogTerminateBeforeEventSignal" - }, - "system.afterEvents.scriptEventReceive": { - "eventType": "ScriptEventCommandMessageAfterEvent", - "signalType": "ScriptEventCommandMessageAfterEventSignal" - }, - "world.beforeEvents.chatSend": { - "eventType": "ChatSendBeforeEvent", - "signalType": "ChatSendBeforeEventSignal" - }, - "world.beforeEvents.effectAdd": { - "eventType": "EffectAddBeforeEvent", - "signalType": "EffectAddBeforeEventSignal" - }, - "world.beforeEvents.entityHeal": { - "eventType": "EntityHealBeforeEvent", - "signalType": "EntityHealBeforeEventSignal" - }, - "world.beforeEvents.entityHurt": { - "eventType": "EntityHurtBeforeEvent", - "signalType": "EntityHurtBeforeEventSignal" - }, - "world.beforeEvents.entityItemPickup": { - "eventType": "EntityItemPickupBeforeEvent", - "signalType": "EntityItemPickupBeforeEventSignal" - }, - "world.beforeEvents.entityRemove": { - "eventType": "EntityRemoveBeforeEvent", - "signalType": "EntityRemoveBeforeEventSignal" - }, - "world.beforeEvents.entityTamed": { - "eventType": "EntityTamedBeforeEvent", - "signalType": "EntityTamedBeforeEventSignal" - }, - "world.beforeEvents.explosion": { - "eventType": "ExplosionBeforeEvent", - "signalType": "ExplosionBeforeEventSignal" - }, - "world.beforeEvents.itemUse": { - "eventType": "ItemUseBeforeEvent", - "signalType": "ItemUseBeforeEventSignal" - }, - "world.beforeEvents.playerBreakBlock": { - "eventType": "PlayerBreakBlockBeforeEvent", - "signalType": "PlayerBreakBlockBeforeEventSignal" - }, - "world.beforeEvents.playerGameModeChange": { - "eventType": "PlayerGameModeChangeBeforeEvent", - "signalType": "PlayerGameModeChangeBeforeEventSignal" - }, - "world.beforeEvents.playerInteractWithBlock": { - "eventType": "PlayerInteractWithBlockBeforeEvent", - "signalType": "PlayerInteractWithBlockBeforeEventSignal" - }, - "world.beforeEvents.playerInteractWithEntity": { - "eventType": "PlayerInteractWithEntityBeforeEvent", - "signalType": "PlayerInteractWithEntityBeforeEventSignal" - }, - "world.beforeEvents.playerLeave": { - "eventType": "PlayerLeaveBeforeEvent", - "signalType": "PlayerLeaveBeforeEventSignal" - }, - "world.beforeEvents.playerPlaceBlock": { - "eventType": "PlayerPlaceBlockBeforeEvent", - "signalType": "PlayerPlaceBlockBeforeEventSignal" - }, - "world.beforeEvents.weatherChange": { - "eventType": "WeatherChangeBeforeEvent", - "signalType": "WeatherChangeBeforeEventSignal" - }, - "world.afterEvents.blockContainerClosed": { - "eventType": "BlockContainerClosedAfterEvent", - "signalType": "BlockContainerClosedAfterEventSignal" - }, - "world.afterEvents.blockContainerOpened": { - "eventType": "BlockContainerOpenedAfterEvent", - "signalType": "BlockContainerOpenedAfterEventSignal" - }, - "world.afterEvents.blockExplode": { - "eventType": "BlockExplodeAfterEvent", - "signalType": "BlockExplodeAfterEventSignal" - }, - "world.afterEvents.buttonPush": { - "eventType": "ButtonPushAfterEvent", - "signalType": "ButtonPushAfterEventSignal" - }, - "world.afterEvents.chatSend": { - "eventType": "ChatSendAfterEvent", - "signalType": "ChatSendAfterEventSignal" - }, - "world.afterEvents.dataDrivenEntityTrigger": { - "eventType": "DataDrivenEntityTriggerAfterEvent", - "signalType": "DataDrivenEntityTriggerAfterEventSignal" - }, - "world.afterEvents.effectAdd": { - "eventType": "EffectAddAfterEvent", - "signalType": "EffectAddAfterEventSignal" - }, - "world.afterEvents.entityContainerClosed": { - "eventType": "EntityContainerClosedAfterEvent", - "signalType": "EntityContainerClosedAfterEventSignal" - }, - "world.afterEvents.entityContainerOpened": { - "eventType": "EntityContainerOpenedAfterEvent", - "signalType": "EntityContainerOpenedAfterEventSignal" - }, - "world.afterEvents.entityDie": { - "eventType": "EntityDieAfterEvent", - "signalType": "EntityDieAfterEventSignal" - }, - "world.afterEvents.entityHeal": { - "eventType": "EntityHealAfterEvent", - "signalType": "EntityHealAfterEventSignal" - }, - "world.afterEvents.entityHealthChanged": { - "eventType": "EntityHealthChangedAfterEvent", - "signalType": "EntityHealthChangedAfterEventSignal" - }, - "world.afterEvents.entityHitBlock": { - "eventType": "EntityHitBlockAfterEvent", - "signalType": "EntityHitBlockAfterEventSignal" - }, - "world.afterEvents.entityHitEntity": { - "eventType": "EntityHitEntityAfterEvent", - "signalType": "EntityHitEntityAfterEventSignal" - }, - "world.afterEvents.entityHurt": { - "eventType": "EntityHurtAfterEvent", - "signalType": "EntityHurtAfterEventSignal" - }, - "world.afterEvents.entityItemDrop": { - "eventType": "EntityItemDropAfterEvent", - "signalType": "EntityItemDropAfterEventSignal" - }, - "world.afterEvents.entityItemPickup": { - "eventType": "EntityItemPickupAfterEvent", - "signalType": "EntityItemPickupAfterEventSignal" - }, - "world.afterEvents.entityLoad": { - "eventType": "EntityLoadAfterEvent", - "signalType": "EntityLoadAfterEventSignal" - }, - "world.afterEvents.entityRemove": { - "eventType": "EntityRemoveAfterEvent", - "signalType": "EntityRemoveAfterEventSignal" - }, - "world.afterEvents.entitySpawn": { - "eventType": "EntitySpawnAfterEvent", - "signalType": "EntitySpawnAfterEventSignal" - }, - "world.afterEvents.entityStartSneaking": { - "eventType": "EntityStartSneakingAfterEvent", - "signalType": "EntityStartSneakingAfterEventSignal" - }, - "world.afterEvents.entityStopSneaking": { - "eventType": "EntityStopSneakingAfterEvent", - "signalType": "EntityStopSneakingAfterEventSignal" - }, - "world.afterEvents.entityTamed": { - "eventType": "EntityTamedAfterEvent", - "signalType": "EntityTamedAfterEventSignal" - }, - "world.afterEvents.entityUpgrade": { - "eventType": "EntityUpgradeAfterEvent", - "signalType": "EntityUpgradeAfterEventSignal" - }, - "world.afterEvents.explosion": { - "eventType": "ExplosionAfterEvent", - "signalType": "ExplosionAfterEventSignal" - }, - "world.afterEvents.gameRuleChange": { - "eventType": "GameRuleChangeAfterEvent", - "signalType": "GameRuleChangeAfterEventSignal" - }, - "world.afterEvents.itemCompleteUse": { - "eventType": "ItemCompleteUseAfterEvent", - "signalType": "ItemCompleteUseAfterEventSignal" - }, - "world.afterEvents.itemReleaseUse": { - "eventType": "ItemReleaseUseAfterEvent", - "signalType": "ItemReleaseUseAfterEventSignal" - }, - "world.afterEvents.itemStartUse": { - "eventType": "ItemStartUseAfterEvent", - "signalType": "ItemStartUseAfterEventSignal" - }, - "world.afterEvents.itemStartUseOn": { - "eventType": "ItemStartUseOnAfterEvent", - "signalType": "ItemStartUseOnAfterEventSignal" - }, - "world.afterEvents.itemStopUse": { - "eventType": "ItemStopUseAfterEvent", - "signalType": "ItemStopUseAfterEventSignal" - }, - "world.afterEvents.itemStopUseOn": { - "eventType": "ItemStopUseOnAfterEvent", - "signalType": "ItemStopUseOnAfterEventSignal" - }, - "world.afterEvents.itemUse": { - "eventType": "ItemUseAfterEvent", - "signalType": "ItemUseAfterEventSignal" - }, - "world.afterEvents.leverAction": { - "eventType": "LeverActionAfterEvent", - "signalType": "LeverActionAfterEventSignal" - }, - "world.afterEvents.messageReceive": { - "eventType": "MessageReceiveAfterEvent", - "signalType": "ServerMessageAfterEventSignal" - }, - "world.afterEvents.packSettingChange": { - "eventType": "PackSettingChangeAfterEvent", - "signalType": "PackSettingChangeAfterEventSignal" - }, - "world.afterEvents.pistonActivate": { - "eventType": "PistonActivateAfterEvent", - "signalType": "PistonActivateAfterEventSignal" - }, - "world.afterEvents.playerBreakBlock": { - "eventType": "PlayerBreakBlockAfterEvent", - "signalType": "PlayerBreakBlockAfterEventSignal" - }, - "world.afterEvents.playerButtonInput": { - "eventType": "PlayerButtonInputAfterEvent", - "signalType": "PlayerButtonInputAfterEventSignal" - }, - "world.afterEvents.playerCancelBreakingBlock": { - "eventType": "PlayerCancelBreakingBlockAfterEvent", - "signalType": "PlayerCancelBreakingBlockAfterEventSignal" - }, - "world.afterEvents.playerDimensionChange": { - "eventType": "PlayerDimensionChangeAfterEvent", - "signalType": "PlayerDimensionChangeAfterEventSignal" - }, - "world.afterEvents.playerEmote": { - "eventType": "PlayerEmoteAfterEvent", - "signalType": "PlayerEmoteAfterEventSignal" - }, - "world.afterEvents.playerGameModeChange": { - "eventType": "PlayerGameModeChangeAfterEvent", - "signalType": "PlayerGameModeChangeAfterEventSignal" - }, - "world.afterEvents.playerHotbarSelectedSlotChange": { - "eventType": "PlayerHotbarSelectedSlotChangeAfterEvent", - "signalType": "PlayerHotbarSelectedSlotChangeAfterEventSignal" - }, - "world.afterEvents.playerInputModeChange": { - "eventType": "PlayerInputModeChangeAfterEvent", - "signalType": "PlayerInputModeChangeAfterEventSignal" - }, - "world.afterEvents.playerInputPermissionCategoryChange": { - "eventType": "PlayerInputPermissionCategoryChangeAfterEvent", - "signalType": "PlayerInputPermissionCategoryChangeAfterEventSignal" - }, - "world.afterEvents.playerInteractWithBlock": { - "eventType": "PlayerInteractWithBlockAfterEvent", - "signalType": "PlayerInteractWithBlockAfterEventSignal" - }, - "world.afterEvents.playerInteractWithEntity": { - "eventType": "PlayerInteractWithEntityAfterEvent", - "signalType": "PlayerInteractWithEntityAfterEventSignal" - }, - "world.afterEvents.playerInventoryItemChange": { - "eventType": "PlayerInventoryItemChangeAfterEvent", - "signalType": "PlayerInventoryItemChangeAfterEventSignal" - }, - "world.afterEvents.playerJoin": { - "eventType": "PlayerJoinAfterEvent", - "signalType": "PlayerJoinAfterEventSignal" - }, - "world.afterEvents.playerLeave": { - "eventType": "PlayerLeaveAfterEvent", - "signalType": "PlayerLeaveAfterEventSignal" - }, - "world.afterEvents.playerPlaceBlock": { - "eventType": "PlayerPlaceBlockAfterEvent", - "signalType": "PlayerPlaceBlockAfterEventSignal" - }, - "world.afterEvents.playerSpawn": { - "eventType": "PlayerSpawnAfterEvent", - "signalType": "PlayerSpawnAfterEventSignal" - }, - "world.afterEvents.playerStartBreakingBlock": { - "eventType": "PlayerStartBreakingBlockAfterEvent", - "signalType": "PlayerStartBreakingBlockAfterEventSignal" - }, - "world.afterEvents.playerSwingStart": { - "eventType": "PlayerSwingStartAfterEvent", - "signalType": "PlayerSwingStartAfterEventSignal" - }, - "world.afterEvents.playerUseNameTag": { - "eventType": "PlayerUseNameTagAfterEvent", - "signalType": "PlayerUseNameTagAfterEventSignal" - }, - "world.afterEvents.pressurePlatePop": { - "eventType": "PressurePlatePopAfterEvent", - "signalType": "PressurePlatePopAfterEventSignal" - }, - "world.afterEvents.pressurePlatePush": { - "eventType": "PressurePlatePushAfterEvent", - "signalType": "PressurePlatePushAfterEventSignal" - }, - "world.afterEvents.projectileHitBlock": { - "eventType": "ProjectileHitBlockAfterEvent", - "signalType": "ProjectileHitBlockAfterEventSignal" - }, - "world.afterEvents.projectileHitEntity": { - "eventType": "ProjectileHitEntityAfterEvent", - "signalType": "ProjectileHitEntityAfterEventSignal" - }, - "world.afterEvents.soundCompleted": { - "eventType": "SoundCompletedAfterEvent", - "signalType": "SoundCompletedAfterEventSignal" - }, - "world.afterEvents.targetBlockHit": { - "eventType": "TargetBlockHitAfterEvent", - "signalType": "TargetBlockHitAfterEventSignal" - }, - "world.afterEvents.tripWireTrip": { - "eventType": "TripWireTripAfterEvent", - "signalType": "TripWireTripAfterEventSignal" - }, - "world.afterEvents.weatherChange": { - "eventType": "WeatherChangeAfterEvent", - "signalType": "WeatherChangeAfterEventSignal" - }, - "world.afterEvents.worldLoad": { - "eventType": "WorldLoadAfterEvent", - "signalType": "WorldLoadAfterEventSignal" - } - } -} as const; - -export type PlaygroundMeta = typeof PLAYGROUND_META; diff --git a/_sandbox_archive/testing/engine/generated/server-l0.ts b/_sandbox_archive/testing/engine/generated/server-l0.ts deleted file mode 100644 index b8b2637e..00000000 --- a/_sandbox_archive/testing/engine/generated/server-l0.ts +++ /dev/null @@ -1,1656 +0,0 @@ -/** - * 由 scripts/gen-mc-fake.mjs 生成 — 勿手改。 - * L0:可 import;未实现成员硬失败。 - */ -import { UnimplementedMinecraftApiError } from "../unimplemented-error.js"; - -function l0Class(apiPath: string) { - return class { - constructor(..._args: unknown[]) { - return new Proxy(this, { - get(target: object, prop: string | symbol, receiver: unknown) { - if (typeof prop === 'symbol') return Reflect.get(target, prop, receiver); - if (prop === 'then') return undefined; - if (prop === 'constructor') return Reflect.get(target, prop, receiver); - throw new UnimplementedMinecraftApiError(`${apiPath}.${String(prop)}`); - }, - }); - } - }; -} - -export const AimAssistCategory = l0Class("AimAssistCategory"); - -export const AimAssistCategorySettings = l0Class("AimAssistCategorySettings"); - -export const AimAssistPreset = l0Class("AimAssistPreset"); - -export const AimAssistPresetSettings = l0Class("AimAssistPresetSettings"); - -export const AimAssistRegistry = l0Class("AimAssistRegistry"); - -export const AimAssistTargetMode = { - "Angle": "Angle", - "Distance": "Distance" -}; - -export const BannerPattern = l0Class("BannerPattern"); - -export const BiomeType = l0Class("BiomeType"); - -export const BiomeTypes = l0Class("BiomeTypes"); - -export const Block = l0Class("Block"); - -export const BlockBoundingBoxUtils = l0Class("BlockBoundingBoxUtils"); - -export const BlockComponent = l0Class("BlockComponent"); - -export const BlockComponentBlockBreakEvent = l0Class("BlockComponentBlockBreakEvent"); - -export const BlockComponentBlockStateChangeEvent = l0Class("BlockComponentBlockStateChangeEvent"); - -export const BlockComponentEntityEvent = l0Class("BlockComponentEntityEvent"); - -export const BlockComponentEntityFallOnEvent = l0Class("BlockComponentEntityFallOnEvent"); - -export const BlockComponentOnPlaceEvent = l0Class("BlockComponentOnPlaceEvent"); - -export const BlockComponentPlayerBreakEvent = l0Class("BlockComponentPlayerBreakEvent"); - -export const BlockComponentPlayerInteractEvent = l0Class("BlockComponentPlayerInteractEvent"); - -export const BlockComponentPlayerPlaceBeforeEvent = l0Class("BlockComponentPlayerPlaceBeforeEvent"); - -export const BlockComponentRandomTickEvent = l0Class("BlockComponentRandomTickEvent"); - -export const BlockComponentRedstoneUpdateEvent = l0Class("BlockComponentRedstoneUpdateEvent"); - -export const BlockComponentRegistry = l0Class("BlockComponentRegistry"); - -export const BlockComponentStepOffEvent = l0Class("BlockComponentStepOffEvent"); - -export const BlockComponentStepOnEvent = l0Class("BlockComponentStepOnEvent"); - -export const BlockComponentTickEvent = l0Class("BlockComponentTickEvent"); - -export const BlockContainerClosedAfterEvent = l0Class("BlockContainerClosedAfterEvent"); - -export const BlockContainerClosedAfterEventSignal = l0Class("BlockContainerClosedAfterEventSignal"); - -export const BlockContainerOpenedAfterEvent = l0Class("BlockContainerOpenedAfterEvent"); - -export const BlockContainerOpenedAfterEventSignal = l0Class("BlockContainerOpenedAfterEventSignal"); - -export const BlockCustomComponentAlreadyRegisteredError = l0Class("BlockCustomComponentAlreadyRegisteredError"); - -export const BlockCustomComponentInstance = l0Class("BlockCustomComponentInstance"); - -export const BlockCustomComponentReloadNewComponentError = l0Class("BlockCustomComponentReloadNewComponentError"); - -export const BlockCustomComponentReloadNewEventError = l0Class("BlockCustomComponentReloadNewEventError"); - -export const BlockCustomComponentReloadVersionError = l0Class("BlockCustomComponentReloadVersionError"); - -export const BlockDynamicPropertiesComponent = l0Class("BlockDynamicPropertiesComponent"); - -export const BlockEvent = l0Class("BlockEvent"); - -export const BlockExplodeAfterEvent = l0Class("BlockExplodeAfterEvent"); - -export const BlockExplodeAfterEventSignal = l0Class("BlockExplodeAfterEventSignal"); - -export const BlockFluidContainerComponent = l0Class("BlockFluidContainerComponent"); - -export const BlockInstrumentComponent = l0Class("BlockInstrumentComponent"); - -export const BlockInventoryComponent = l0Class("BlockInventoryComponent"); - -export const BlockLocationIterator = l0Class("BlockLocationIterator"); - -export const BlockMapColorComponent = l0Class("BlockMapColorComponent"); - -export const BlockMovableComponent = l0Class("BlockMovableComponent"); - -export const BlockPistonComponent = l0Class("BlockPistonComponent"); - -export const BlockPistonState = { - "Expanded": "Expanded", - "Expanding": "Expanding", - "Retracted": "Retracted", - "Retracting": "Retracting" -}; - -export const BlockPrecipitationInteractionsComponent = l0Class("BlockPrecipitationInteractionsComponent"); - -export const BlockRecordPlayerComponent = l0Class("BlockRecordPlayerComponent"); - -export const BlockRedstoneProducerComponent = l0Class("BlockRedstoneProducerComponent"); - -export const BlockSignComponent = l0Class("BlockSignComponent"); - -export const BlockStates = l0Class("BlockStates"); - -export const BlockStateType = l0Class("BlockStateType"); - -export const BlockType = l0Class("BlockType"); - -export const BlockTypes = l0Class("BlockTypes"); - -export const BlockVolume = l0Class("BlockVolume"); - -export const BlockVolumeBase = l0Class("BlockVolumeBase"); - -export const BlockVolumeIntersection = { - "Disjoint": 0, - "Contains": 1, - "Intersects": 2 -}; - -export const BookError = l0Class("BookError"); - -export const BookErrorReason = { - "ExceedsMaxPageLength": "ExceedsMaxPageLength", - "ExceedsMaxPages": "ExceedsMaxPages", - "ExceedsTitleLength": "ExceedsTitleLength" -}; - -export const BookPageContentError = l0Class("BookPageContentError"); - -export const ButtonPushAfterEvent = l0Class("ButtonPushAfterEvent"); - -export const ButtonPushAfterEventSignal = l0Class("ButtonPushAfterEventSignal"); - -export const ButtonState = { - "Pressed": "Pressed", - "Released": "Released" -}; - -export const Camera = l0Class("Camera"); - -export const CameraShakeType = { - "Positional": "Positional", - "Rotational": "Rotational" -}; - -export const CarryOverBlockEntityDataFunction = l0Class("CarryOverBlockEntityDataFunction"); - -export const CatmullRomSpline = l0Class("CatmullRomSpline"); - -export const ChatSendAfterEvent = l0Class("ChatSendAfterEvent"); - -export const ChatSendAfterEventSignal = l0Class("ChatSendAfterEventSignal"); - -export const ChatSendBeforeEvent = l0Class("ChatSendBeforeEvent"); - -export const ChatSendBeforeEventSignal = l0Class("ChatSendBeforeEventSignal"); - -export const ClientSystemInfo = l0Class("ClientSystemInfo"); - -export const CloneMode = { - "Copy": 0, - "ForceCopy": 1, - "Move": 2 -}; - -export const CommandError = l0Class("CommandError"); - -export const CommandPermissionLevel = { - "Any": 0, - "GameDirectors": 1, - "Admin": 2, - "Host": 3, - "Owner": 4 -}; - -export const CommandResult = l0Class("CommandResult"); - -export const Component = l0Class("Component"); - -export const CompoundBlockVolume = l0Class("CompoundBlockVolume"); - -export const CompoundBlockVolumeAction = { - "Add": 0, - "Subtract": 1 -}; - -export const CompoundBlockVolumePositionRelativity = { - "Relative": 0, - "Absolute": 1 -}; - -export const Container = l0Class("Container"); - -export const ContainerRulesError = l0Class("ContainerRulesError"); - -export const ContainerRulesErrorReason = {}; - -export const ContainerSlot = l0Class("ContainerSlot"); - -export const ControlScheme = { - "CameraRelative": "CameraRelative", - "CameraRelativeStrafe": "CameraRelativeStrafe", - "LockedPlayerRelativeStrafe": "LockedPlayerRelativeStrafe", - "PlayerRelative": "PlayerRelative", - "PlayerRelativeStrafe": "PlayerRelativeStrafe" -}; - -export const CustomCommandError = l0Class("CustomCommandError"); - -export const CustomCommandErrorReason = { - "AlreadyRegistered": "AlreadyRegistered", - "EnumDependencyMissing": "EnumDependencyMissing", - "NamespaceMismatch": "NamespaceMismatch", - "ParameterLimit": "ParameterLimit", - "RegistryInvalid": "RegistryInvalid", - "RegistryReadOnly": "RegistryReadOnly", - "UnexpectedEnumName": "UnexpectedEnumName" -}; - -export const CustomCommandOrigin = l0Class("CustomCommandOrigin"); - -export const CustomCommandParamType = {}; - -export const CustomCommandRegistry = l0Class("CustomCommandRegistry"); - -export const CustomCommandSource = { - "Block": "Block", - "Entity": "Entity", - "NPCDialogue": "NPCDialogue", - "Server": "Server" -}; - -export const CustomCommandStatus = { - "Success": 0, - "Failure": 1 -}; - -export const CustomComponentInvalidRegistryError = l0Class("CustomComponentInvalidRegistryError"); - -export const CustomComponentNameError = l0Class("CustomComponentNameError"); - -export const CustomComponentNameErrorReason = { - "NoNamespace": 1, - "DisallowedNamespace": 2 -}; - -export const CustomComponentParameters = l0Class("CustomComponentParameters"); - -export const CustomDimensionAlreadyRegisteredError = l0Class("CustomDimensionAlreadyRegisteredError"); - -export const CustomDimensionInvalidRegistryError = l0Class("CustomDimensionInvalidRegistryError"); - -export const CustomDimensionNameError = l0Class("CustomDimensionNameError"); - -export const CustomDimensionReloadNewDimensionError = l0Class("CustomDimensionReloadNewDimensionError"); - -export const DamagedByEntityCondition = l0Class("DamagedByEntityCondition"); - -export const DataDrivenEntityTriggerAfterEvent = l0Class("DataDrivenEntityTriggerAfterEvent"); - -export const DataDrivenEntityTriggerAfterEventSignal = l0Class("DataDrivenEntityTriggerAfterEventSignal"); - -export const Difficulty = { - "Easy": "Easy", - "Hard": "Hard", - "Normal": "Normal", - "Peaceful": "Peaceful" -}; - -export const DimensionRegistry = l0Class("DimensionRegistry"); - -export const DimensionType = l0Class("DimensionType"); - -export const DimensionTypes = l0Class("DimensionTypes"); - -export const Direction = {}; - -export const DisplaySlotId = { - "BelowName": "BelowName", - "List": "List", - "Sidebar": "Sidebar" -}; - -export const DyeColor = { - "Black": "Black", - "Blue": "Blue", - "Brown": "Brown", - "Cyan": "Cyan", - "Gray": "Gray", - "Green": "Green", - "LightBlue": "LightBlue", - "Lime": "Lime", - "Magenta": "Magenta", - "Orange": "Orange", - "Pink": "Pink", - "Purple": "Purple", - "Red": "Red", - "Silver": "Silver", - "White": "White", - "Yellow": "Yellow" -}; - -export const EasingType = { - "InBack": "InBack", - "InBounce": "InBounce", - "InCirc": "InCirc", - "InCubic": "InCubic", - "InElastic": "InElastic", - "InExpo": "InExpo", - "InOutBack": "InOutBack", - "InOutBounce": "InOutBounce", - "InOutCirc": "InOutCirc", - "InOutCubic": "InOutCubic", - "InOutElastic": "InOutElastic", - "InOutExpo": "InOutExpo", - "InOutQuad": "InOutQuad", - "InOutQuart": "InOutQuart", - "InOutQuint": "InOutQuint", - "InOutSine": "InOutSine", - "InQuad": "InQuad", - "InQuart": "InQuart", - "InQuint": "InQuint", - "InSine": "InSine", - "Linear": "Linear", - "OutBack": "OutBack", - "OutBounce": "OutBounce", - "OutCirc": "OutCirc", - "OutCubic": "OutCubic", - "OutElastic": "OutElastic", - "OutExpo": "OutExpo", - "OutQuad": "OutQuad", - "OutQuart": "OutQuart", - "OutQuint": "OutQuint", - "OutSine": "OutSine", - "Spring": "Spring" -}; - -export const EffectAddAfterEvent = l0Class("EffectAddAfterEvent"); - -export const EffectAddAfterEventSignal = l0Class("EffectAddAfterEventSignal"); - -export const EffectAddBeforeEvent = l0Class("EffectAddBeforeEvent"); - -export const EffectAddBeforeEventSignal = l0Class("EffectAddBeforeEventSignal"); - -export const EffectType = l0Class("EffectType"); - -export const EffectTypes = l0Class("EffectTypes"); - -export const EmptyLootItem = l0Class("EmptyLootItem"); - -export const EnchantInfo = l0Class("EnchantInfo"); - -export const EnchantmentLevelOutOfBoundsError = l0Class("EnchantmentLevelOutOfBoundsError"); - -export const EnchantmentSlot = { - "ArmorFeet": "ArmorFeet", - "ArmorHead": "ArmorHead", - "ArmorLegs": "ArmorLegs", - "ArmorTorso": "ArmorTorso", - "Axe": "Axe", - "Bow": "Bow", - "CarrotStick": "CarrotStick", - "CosmeticHead": "CosmeticHead", - "Crossbow": "Crossbow", - "Elytra": "Elytra", - "FishingRod": "FishingRod", - "Flintsteel": "Flintsteel", - "Hoe": "Hoe", - "MeleeSpear": "MeleeSpear", - "Pickaxe": "Pickaxe", - "Shears": "Shears", - "Shield": "Shield", - "Shovel": "Shovel", - "Spear": "Spear", - "Sword": "Sword" -}; - -export const EnchantmentType = l0Class("EnchantmentType"); - -export const EnchantmentTypeNotCompatibleError = l0Class("EnchantmentTypeNotCompatibleError"); - -export const EnchantmentTypes = l0Class("EnchantmentTypes"); - -export const EnchantmentTypeUnknownIdError = l0Class("EnchantmentTypeUnknownIdError"); - -export const EnchantRandomEquipmentFunction = l0Class("EnchantRandomEquipmentFunction"); - -export const EnchantRandomlyFunction = l0Class("EnchantRandomlyFunction"); - -export const EnchantWithLevelsFunction = l0Class("EnchantWithLevelsFunction"); - -export const EntityAddRiderComponent = l0Class("EntityAddRiderComponent"); - -export const EntityAgeableComponent = l0Class("EntityAgeableComponent"); - -export const EntityAttachPoint = { - "Body": "Body", - "BreathingPoint": "BreathingPoint", - "DropAttachPoint": "DropAttachPoint", - "ExplosionPoint": "ExplosionPoint", - "Eyes": "Eyes", - "Feet": "Feet", - "Head": "Head", - "Mouth": "Mouth", - "WeaponAttachPoint": "WeaponAttachPoint" -}; - -export const EntityAttributeComponent = l0Class("EntityAttributeComponent"); - -export const EntityBaseMovementComponent = l0Class("EntityBaseMovementComponent"); - -export const EntityBreathableComponent = l0Class("EntityBreathableComponent"); - -export const EntityCanClimbComponent = l0Class("EntityCanClimbComponent"); - -export const EntityCanFlyComponent = l0Class("EntityCanFlyComponent"); - -export const EntityCanPowerJumpComponent = l0Class("EntityCanPowerJumpComponent"); - -export const EntityColor2Component = l0Class("EntityColor2Component"); - -export const EntityColorComponent = l0Class("EntityColorComponent"); - -export const EntityComponent = l0Class("EntityComponent"); - -export const EntityComponentTypes = { - "AddRider": "minecraft:addrider", - "Ageable": "minecraft:ageable", - "Breathable": "minecraft:breathable", - "CanClimb": "minecraft:can_climb", - "CanFly": "minecraft:can_fly", - "CanPowerJump": "minecraft:can_power_jump", - "Color": "minecraft:color", - "Color2": "minecraft:color2", - "CursorInventory": "minecraft:cursor_inventory", - "EnderInventory": "minecraft:ender_inventory", - "Equippable": "minecraft:equippable", - "FireImmune": "minecraft:fire_immune", - "FloatsInLiquid": "minecraft:floats_in_liquid", - "FlyingSpeed": "minecraft:flying_speed", - "FrictionModifier": "minecraft:friction_modifier", - "Healable": "minecraft:healable", - "Health": "minecraft:health", - "Inventory": "minecraft:inventory", - "IsBaby": "minecraft:is_baby", - "IsCharged": "minecraft:is_charged", - "IsChested": "minecraft:is_chested", - "IsDyeable": "minecraft:is_dyeable", - "IsHiddenWhenInvisible": "minecraft:is_hidden_when_invisible", - "IsIgnited": "minecraft:is_ignited", - "IsIllagerCaptain": "minecraft:is_illager_captain", - "IsSaddled": "minecraft:is_saddled", - "IsShaking": "minecraft:is_shaking", - "IsSheared": "minecraft:is_sheared", - "IsStackable": "minecraft:is_stackable", - "IsStunned": "minecraft:is_stunned", - "IsTamed": "minecraft:is_tamed", - "Item": "minecraft:item", - "LavaMovement": "minecraft:lava_movement", - "Leashable": "minecraft:leashable", - "MarkVariant": "minecraft:mark_variant", - "Movement": "minecraft:movement", - "MovementAmphibious": "minecraft:movement.amphibious", - "MovementBasic": "minecraft:movement.basic", - "MovementFly": "minecraft:movement.fly", - "MovementGeneric": "minecraft:movement.generic", - "MovementGlide": "minecraft:movement.glide", - "MovementHover": "minecraft:movement.hover", - "MovementJump": "minecraft:movement.jump", - "MovementSkip": "minecraft:movement.skip", - "MovementSway": "minecraft:movement.sway", - "NavigationClimb": "minecraft:navigation.climb", - "NavigationFloat": "minecraft:navigation.float", - "NavigationFly": "minecraft:navigation.fly", - "NavigationGeneric": "minecraft:navigation.generic", - "NavigationHover": "minecraft:navigation.hover", - "NavigationWalk": "minecraft:navigation.walk", - "Npc": "minecraft:npc", - "OnFire": "minecraft:onfire", - "Exhaustion": "minecraft:player.exhaustion", - "Hunger": "minecraft:player.hunger", - "Saturation": "minecraft:player.saturation", - "Projectile": "minecraft:projectile", - "PushThrough": "minecraft:push_through", - "Rideable": "minecraft:rideable", - "Riding": "minecraft:riding", - "Scale": "minecraft:scale", - "SkinId": "minecraft:skin_id", - "Strength": "minecraft:strength", - "Tameable": "minecraft:tameable", - "TameMount": "minecraft:tamemount", - "TypeFamily": "minecraft:type_family", - "UnderwaterMovement": "minecraft:underwater_movement", - "Variant": "minecraft:variant", - "WantsJockey": "minecraft:wants_jockey" -}; - -export const EntityContainerClosedAfterEvent = l0Class("EntityContainerClosedAfterEvent"); - -export const EntityContainerClosedAfterEventSignal = l0Class("EntityContainerClosedAfterEventSignal"); - -export const EntityContainerOpenedAfterEvent = l0Class("EntityContainerOpenedAfterEvent"); - -export const EntityContainerOpenedAfterEventSignal = l0Class("EntityContainerOpenedAfterEventSignal"); - -export const EntityDamageCause = { - "anvil": "anvil", - "blockExplosion": "blockExplosion", - "campfire": "campfire", - "charging": "charging", - "contact": "contact", - "drowning": "drowning", - "entityAttack": "entityAttack", - "entityExplosion": "entityExplosion", - "fall": "fall", - "fallingBlock": "fallingBlock", - "fire": "fire", - "fireTick": "fireTick", - "fireworks": "fireworks", - "flyIntoWall": "flyIntoWall", - "freezing": "freezing", - "lava": "lava", - "lightning": "lightning", - "maceSmash": "maceSmash", - "magic": "magic", - "magma": "magma", - "none": "none", - "override": "override", - "piston": "piston", - "projectile": "projectile", - "ramAttack": "ramAttack", - "selfDestruct": "selfDestruct", - "sonicBoom": "sonicBoom", - "soulCampfire": "soulCampfire", - "stalactite": "stalactite", - "stalagmite": "stalagmite", - "starve": "starve", - "suffocation": "suffocation", - "temperature": "temperature", - "thorns": "thorns", - "wither": "wither" -}; - -export const EntityDefinitionFeedItem = l0Class("EntityDefinitionFeedItem"); - -export const EntityDieAfterEvent = l0Class("EntityDieAfterEvent"); - -export const EntityDieAfterEventSignal = l0Class("EntityDieAfterEventSignal"); - -export const EntityEnderInventoryComponent = l0Class("EntityEnderInventoryComponent"); - -export const EntityEquippableComponent = l0Class("EntityEquippableComponent"); - -export const EntityExhaustionComponent = l0Class("EntityExhaustionComponent"); - -export const EntityFireImmuneComponent = l0Class("EntityFireImmuneComponent"); - -export const EntityFloatsInLiquidComponent = l0Class("EntityFloatsInLiquidComponent"); - -export const EntityFlyingSpeedComponent = l0Class("EntityFlyingSpeedComponent"); - -export const EntityFrictionModifierComponent = l0Class("EntityFrictionModifierComponent"); - -export const EntityHasMarkVariantCondition = l0Class("EntityHasMarkVariantCondition"); - -export const EntityHasVariantCondition = l0Class("EntityHasVariantCondition"); - -export const EntityHealableComponent = l0Class("EntityHealableComponent"); - -export const EntityHealAfterEvent = l0Class("EntityHealAfterEvent"); - -export const EntityHealAfterEventSignal = l0Class("EntityHealAfterEventSignal"); - -export const EntityHealBeforeEvent = l0Class("EntityHealBeforeEvent"); - -export const EntityHealBeforeEventSignal = l0Class("EntityHealBeforeEventSignal"); - -export const EntityHealCause = { - "Heal": "Heal", - "Regeneration": "Regeneration", - "SelfHeal": "SelfHeal", - "TotemOfUndying": "TotemOfUndying" -}; - -export const EntityHealSource = l0Class("EntityHealSource"); - -export const EntityHealthChangedAfterEvent = l0Class("EntityHealthChangedAfterEvent"); - -export const EntityHealthChangedAfterEventSignal = l0Class("EntityHealthChangedAfterEventSignal"); - -export const EntityHitBlockAfterEvent = l0Class("EntityHitBlockAfterEvent"); - -export const EntityHitBlockAfterEventSignal = l0Class("EntityHitBlockAfterEventSignal"); - -export const EntityHitEntityAfterEvent = l0Class("EntityHitEntityAfterEvent"); - -export const EntityHitEntityAfterEventSignal = l0Class("EntityHitEntityAfterEventSignal"); - -export const EntityHungerComponent = l0Class("EntityHungerComponent"); - -export const EntityHurtAfterEvent = l0Class("EntityHurtAfterEvent"); - -export const EntityHurtAfterEventSignal = l0Class("EntityHurtAfterEventSignal"); - -export const EntityHurtBeforeEvent = l0Class("EntityHurtBeforeEvent"); - -export const EntityHurtBeforeEventSignal = l0Class("EntityHurtBeforeEventSignal"); - -export const EntityIsBabyComponent = l0Class("EntityIsBabyComponent"); - -export const EntityIsChargedComponent = l0Class("EntityIsChargedComponent"); - -export const EntityIsChestedComponent = l0Class("EntityIsChestedComponent"); - -export const EntityIsDyeableComponent = l0Class("EntityIsDyeableComponent"); - -export const EntityIsHiddenWhenInvisibleComponent = l0Class("EntityIsHiddenWhenInvisibleComponent"); - -export const EntityIsIgnitedComponent = l0Class("EntityIsIgnitedComponent"); - -export const EntityIsIllagerCaptainComponent = l0Class("EntityIsIllagerCaptainComponent"); - -export const EntityIsSaddledComponent = l0Class("EntityIsSaddledComponent"); - -export const EntityIsShakingComponent = l0Class("EntityIsShakingComponent"); - -export const EntityIsShearedComponent = l0Class("EntityIsShearedComponent"); - -export const EntityIsStackableComponent = l0Class("EntityIsStackableComponent"); - -export const EntityIsStunnedComponent = l0Class("EntityIsStunnedComponent"); - -export const EntityIsTamedComponent = l0Class("EntityIsTamedComponent"); - -export const EntityItemComponent = l0Class("EntityItemComponent"); - -export const EntityItemDropAfterEvent = l0Class("EntityItemDropAfterEvent"); - -export const EntityItemDropAfterEventSignal = l0Class("EntityItemDropAfterEventSignal"); - -export const EntityItemPickupAfterEvent = l0Class("EntityItemPickupAfterEvent"); - -export const EntityItemPickupAfterEventSignal = l0Class("EntityItemPickupAfterEventSignal"); - -export const EntityItemPickupBeforeEvent = l0Class("EntityItemPickupBeforeEvent"); - -export const EntityItemPickupBeforeEventSignal = l0Class("EntityItemPickupBeforeEventSignal"); - -export const EntityKilledCondition = l0Class("EntityKilledCondition"); - -export const EntityLavaMovementComponent = l0Class("EntityLavaMovementComponent"); - -export const EntityLeashableComponent = l0Class("EntityLeashableComponent"); - -export const EntityLoadAfterEvent = l0Class("EntityLoadAfterEvent"); - -export const EntityLoadAfterEventSignal = l0Class("EntityLoadAfterEventSignal"); - -export const EntityMarkVariantComponent = l0Class("EntityMarkVariantComponent"); - -export const EntityMovementAmphibiousComponent = l0Class("EntityMovementAmphibiousComponent"); - -export const EntityMovementBasicComponent = l0Class("EntityMovementBasicComponent"); - -export const EntityMovementComponent = l0Class("EntityMovementComponent"); - -export const EntityMovementFlyComponent = l0Class("EntityMovementFlyComponent"); - -export const EntityMovementGenericComponent = l0Class("EntityMovementGenericComponent"); - -export const EntityMovementGlideComponent = l0Class("EntityMovementGlideComponent"); - -export const EntityMovementHoverComponent = l0Class("EntityMovementHoverComponent"); - -export const EntityMovementJumpComponent = l0Class("EntityMovementJumpComponent"); - -export const EntityMovementSkipComponent = l0Class("EntityMovementSkipComponent"); - -export const EntityMovementSwayComponent = l0Class("EntityMovementSwayComponent"); - -export const EntityNavigationClimbComponent = l0Class("EntityNavigationClimbComponent"); - -export const EntityNavigationComponent = l0Class("EntityNavigationComponent"); - -export const EntityNavigationFloatComponent = l0Class("EntityNavigationFloatComponent"); - -export const EntityNavigationFlyComponent = l0Class("EntityNavigationFlyComponent"); - -export const EntityNavigationGenericComponent = l0Class("EntityNavigationGenericComponent"); - -export const EntityNavigationHoverComponent = l0Class("EntityNavigationHoverComponent"); - -export const EntityNavigationWalkComponent = l0Class("EntityNavigationWalkComponent"); - -export const EntityNpcComponent = l0Class("EntityNpcComponent"); - -export const EntityOnFireComponent = l0Class("EntityOnFireComponent"); - -export const EntityProjectileComponent = l0Class("EntityProjectileComponent"); - -export const EntityPushThroughComponent = l0Class("EntityPushThroughComponent"); - -export const EntityRemoveAfterEvent = l0Class("EntityRemoveAfterEvent"); - -export const EntityRemoveAfterEventSignal = l0Class("EntityRemoveAfterEventSignal"); - -export const EntityRemoveBeforeEvent = l0Class("EntityRemoveBeforeEvent"); - -export const EntityRemoveBeforeEventSignal = l0Class("EntityRemoveBeforeEventSignal"); - -export const EntityRideableComponent = l0Class("EntityRideableComponent"); - -export const EntityRidingComponent = l0Class("EntityRidingComponent"); - -export const EntitySaturationComponent = l0Class("EntitySaturationComponent"); - -export const EntityScaleComponent = l0Class("EntityScaleComponent"); - -export const EntitySkinIdComponent = l0Class("EntitySkinIdComponent"); - -export const EntitySpawnAfterEvent = l0Class("EntitySpawnAfterEvent"); - -export const EntitySpawnAfterEventSignal = l0Class("EntitySpawnAfterEventSignal"); - -export const EntitySpawnError = l0Class("EntitySpawnError"); - -export const EntityStartSneakingAfterEvent = l0Class("EntityStartSneakingAfterEvent"); - -export const EntityStartSneakingAfterEventSignal = l0Class("EntityStartSneakingAfterEventSignal"); - -export const EntityStopSneakingAfterEvent = l0Class("EntityStopSneakingAfterEvent"); - -export const EntityStopSneakingAfterEventSignal = l0Class("EntityStopSneakingAfterEventSignal"); - -export const EntityStrengthComponent = l0Class("EntityStrengthComponent"); - -export const EntitySwingSource = { - "Attack": "Attack", - "Build": "Build", - "DropItem": "DropItem", - "Event": "Event", - "Interact": "Interact", - "Mine": "Mine", - "None": "None", - "ThrowItem": "ThrowItem", - "UseItem": "UseItem" -}; - -export const EntityTameableComponent = l0Class("EntityTameableComponent"); - -export const EntityTamedAfterEvent = l0Class("EntityTamedAfterEvent"); - -export const EntityTamedAfterEventSignal = l0Class("EntityTamedAfterEventSignal"); - -export const EntityTamedBeforeEvent = l0Class("EntityTamedBeforeEvent"); - -export const EntityTamedBeforeEventSignal = l0Class("EntityTamedBeforeEventSignal"); - -export const EntityTameMountComponent = l0Class("EntityTameMountComponent"); - -export const EntityType = l0Class("EntityType"); - -export const EntityTypeFamilyComponent = l0Class("EntityTypeFamilyComponent"); - -export const EntityTypes = l0Class("EntityTypes"); - -export const EntityUnderwaterMovementComponent = l0Class("EntityUnderwaterMovementComponent"); - -export const EntityUpgradeAfterEvent = l0Class("EntityUpgradeAfterEvent"); - -export const EntityUpgradeAfterEventSignal = l0Class("EntityUpgradeAfterEventSignal"); - -export const EntityVariantComponent = l0Class("EntityVariantComponent"); - -export const EntityWantsJockeyComponent = l0Class("EntityWantsJockeyComponent"); - -export const EntityWaypoint = l0Class("EntityWaypoint"); - -export const EquipmentSlot = { - "Body": "Body", - "Chest": "Chest", - "Feet": "Feet", - "Head": "Head", - "Legs": "Legs", - "Mainhand": "Mainhand", - "Offhand": "Offhand" -}; - -export const ExplorationMapFunction = l0Class("ExplorationMapFunction"); - -export const ExplosionAfterEvent = l0Class("ExplosionAfterEvent"); - -export const ExplosionAfterEventSignal = l0Class("ExplosionAfterEventSignal"); - -export const ExplosionBeforeEvent = l0Class("ExplosionBeforeEvent"); - -export const ExplosionBeforeEventSignal = l0Class("ExplosionBeforeEventSignal"); - -export const ExplosionDecayFunction = l0Class("ExplosionDecayFunction"); - -export const FeedItem = l0Class("FeedItem"); - -export const FeedItemEffect = l0Class("FeedItemEffect"); - -export const FillContainerFunction = l0Class("FillContainerFunction"); - -export const FluidContainer = l0Class("FluidContainer"); - -export const FluidType = { - "Lava": "Lava", - "Potion": "Potion", - "PowderSnow": "PowderSnow", - "Water": "Water" -}; - -export const FogSettings = l0Class("FogSettings"); - -export const FogSettingsError = l0Class("FogSettingsError"); - -export const GameRule = { - "CommandBlockOutput": "commandBlockOutput", - "CommandBlocksEnabled": "commandBlocksEnabled", - "DoDayLightCycle": "doDayLightCycle", - "DoEntityDrops": "doEntityDrops", - "DoFireTick": "doFireTick", - "DoImmediateRespawn": "doImmediateRespawn", - "DoInsomnia": "doInsomnia", - "DoLimitedCrafting": "doLimitedCrafting", - "DoMobLoot": "doMobLoot", - "DoMobSpawning": "doMobSpawning", - "DoTileDrops": "doTileDrops", - "DoWeatherCycle": "doWeatherCycle", - "DrowningDamage": "drowningDamage", - "FallDamage": "fallDamage", - "FireDamage": "fireDamage", - "FreezeDamage": "freezeDamage", - "FunctionCommandLimit": "functionCommandLimit", - "KeepInventory": "keepInventory", - "MaxCommandChainLength": "maxCommandChainLength", - "MobGriefing": "mobGriefing", - "NaturalRegeneration": "naturalRegeneration", - "PlayersSleepingPercentage": "playersSleepingPercentage", - "PlayerWaypoints": "playerWaypoints", - "ProjectilesCanBreakBlocks": "projectilesCanBreakBlocks", - "Pvp": "pvp", - "RandomTickSpeed": "randomTickSpeed", - "RecipesUnlock": "recipesUnlock", - "RespawnBlocksExplode": "respawnBlocksExplode", - "SendCommandFeedback": "sendCommandFeedback", - "ShowBorderEffect": "showBorderEffect", - "ShowCoordinates": "showCoordinates", - "ShowDaysPlayed": "showDaysPlayed", - "ShowDeathMessages": "showDeathMessages", - "ShowRecipeMessages": "showRecipeMessages", - "ShowTags": "showTags", - "SpawnRadius": "spawnRadius", - "TntExplodes": "tntExplodes", - "TntExplosionDropDecay": "tntExplosionDropDecay" -}; - -export const GameRuleChangeAfterEvent = l0Class("GameRuleChangeAfterEvent"); - -export const GameRuleChangeAfterEventSignal = l0Class("GameRuleChangeAfterEventSignal"); - -export const GameRules = l0Class("GameRules"); - -export const GraphicsMode = { - "Deferred": "Deferred", - "Fancy": "Fancy", - "RayTraced": "RayTraced", - "Simple": "Simple" -}; - -export const HeldItemOption = { - "AnyItem": "AnyItem", - "NoItem": "NoItem" -}; - -export const HudElement = { - "PaperDoll": 0, - "Armor": 1, - "ToolTips": 2, - "TouchControls": 3, - "Crosshair": 4, - "Hotbar": 5, - "Health": 6, - "ProgressBar": 7, - "Hunger": 8, - "AirBubbles": 9, - "HorseHealth": 10, - "StatusEffects": 11, - "ItemText": 12 -}; - -export const HudElementsCount = new Proxy({}, { - get(_t: object, prop: string | symbol) { - if (typeof prop === 'symbol') return undefined; - if (prop === 'then') return undefined; - throw new UnimplementedMinecraftApiError("HudElementsCount." + String(prop)); - }, -}); - -export const HudVisibility = { - "Hide": 0, - "Reset": 1 -}; - -export const HudVisibilityCount = new Proxy({}, { - get(_t: object, prop: string | symbol) { - if (typeof prop === 'symbol') return undefined; - if (prop === 'then') return undefined; - throw new UnimplementedMinecraftApiError("HudVisibilityCount." + String(prop)); - }, -}); - -export const InputButton = { - "Jump": "Jump", - "Sneak": "Sneak" -}; - -export const InputInfo = l0Class("InputInfo"); - -export const InputMode = { - "Gamepad": "Gamepad", - "KeyboardAndMouse": "KeyboardAndMouse", - "MotionController": "MotionController", - "Touch": "Touch" -}; - -export const InputPermissionCategory = { - "Camera": 1, - "Movement": 2, - "LateralMovement": 4, - "Sneak": 5, - "Jump": 6, - "Mount": 7, - "Dismount": 8, - "MoveForward": 9, - "MoveBackward": 10, - "MoveLeft": 11, - "MoveRight": 12 -}; - -export const InvalidBlockComponentError = l0Class("InvalidBlockComponentError"); - -export const InvalidContainerError = l0Class("InvalidContainerError"); - -export const InvalidContainerSlotError = l0Class("InvalidContainerSlotError"); - -export const InvalidEntityComponentError = l0Class("InvalidEntityComponentError"); - -export const InvalidEntityError = l0Class("InvalidEntityError"); - -export const InvalidItemStackError = l0Class("InvalidItemStackError"); - -export const InvalidIteratorError = l0Class("InvalidIteratorError"); - -export const InvalidPotionDeliveryTypeError = l0Class("InvalidPotionDeliveryTypeError"); - -export const InvalidPotionEffectTypeError = l0Class("InvalidPotionEffectTypeError"); - -export const InvalidStructureError = l0Class("InvalidStructureError"); - -export const InvalidWaypointError = l0Class("InvalidWaypointError"); - -export const InvalidWaypointTextureSelectorError = l0Class("InvalidWaypointTextureSelectorError"); - -export const IsBabyCondition = l0Class("IsBabyCondition"); - -export const ISerializable = l0Class("ISerializable"); - -export const ItemBlockDynamicPropertiesComponent = l0Class("ItemBlockDynamicPropertiesComponent"); - -export const ItemBookComponent = l0Class("ItemBookComponent"); - -export const ItemCompleteUseAfterEvent = l0Class("ItemCompleteUseAfterEvent"); - -export const ItemCompleteUseAfterEventSignal = l0Class("ItemCompleteUseAfterEventSignal"); - -export const ItemCompleteUseEvent = l0Class("ItemCompleteUseEvent"); - -export const ItemComponent = l0Class("ItemComponent"); - -export const ItemComponentBeforeDurabilityDamageEvent = l0Class("ItemComponentBeforeDurabilityDamageEvent"); - -export const ItemComponentCompleteUseEvent = l0Class("ItemComponentCompleteUseEvent"); - -export const ItemComponentConsumeEvent = l0Class("ItemComponentConsumeEvent"); - -export const ItemComponentHitEntityEvent = l0Class("ItemComponentHitEntityEvent"); - -export const ItemComponentMineBlockEvent = l0Class("ItemComponentMineBlockEvent"); - -export const ItemComponentRegistry = l0Class("ItemComponentRegistry"); - -export const ItemComponentTypes = { - "BlockDynamicProperties": "minecraft:block_actor_dynamic_properties", - "Book": "minecraft:book", - "Compostable": "minecraft:compostable", - "Cooldown": "minecraft:cooldown", - "Durability": "minecraft:durability", - "Dyeable": "minecraft:dyeable", - "Enchantable": "minecraft:enchantable", - "Food": "minecraft:food", - "Inventory": "minecraft:inventory", - "Potion": "minecraft:potion" -}; - -export const ItemComponentUseEvent = l0Class("ItemComponentUseEvent"); - -export const ItemComponentUseOnEvent = l0Class("ItemComponentUseOnEvent"); - -export const ItemCompostableComponent = l0Class("ItemCompostableComponent"); - -export const ItemCooldownComponent = l0Class("ItemCooldownComponent"); - -export const ItemCustomComponentAlreadyRegisteredError = l0Class("ItemCustomComponentAlreadyRegisteredError"); - -export const ItemCustomComponentInstance = l0Class("ItemCustomComponentInstance"); - -export const ItemCustomComponentReloadNewComponentError = l0Class("ItemCustomComponentReloadNewComponentError"); - -export const ItemCustomComponentReloadNewEventError = l0Class("ItemCustomComponentReloadNewEventError"); - -export const ItemCustomComponentReloadVersionError = l0Class("ItemCustomComponentReloadVersionError"); - -export const ItemDurabilityComponent = l0Class("ItemDurabilityComponent"); - -export const ItemDyeableComponent = l0Class("ItemDyeableComponent"); - -export const ItemEnchantableComponent = l0Class("ItemEnchantableComponent"); - -export const ItemFoodComponent = l0Class("ItemFoodComponent"); - -export const ItemInventoryComponent = l0Class("ItemInventoryComponent"); - -export const ItemLockMode = { - "inventory": "inventory", - "none": "none", - "slot": "slot" -}; - -export const ItemPotionComponent = l0Class("ItemPotionComponent"); - -export const ItemReleaseUseAfterEvent = l0Class("ItemReleaseUseAfterEvent"); - -export const ItemReleaseUseAfterEventSignal = l0Class("ItemReleaseUseAfterEventSignal"); - -export const ItemStartUseAfterEvent = l0Class("ItemStartUseAfterEvent"); - -export const ItemStartUseAfterEventSignal = l0Class("ItemStartUseAfterEventSignal"); - -export const ItemStartUseOnAfterEvent = l0Class("ItemStartUseOnAfterEvent"); - -export const ItemStartUseOnAfterEventSignal = l0Class("ItemStartUseOnAfterEventSignal"); - -export const ItemStopUseAfterEvent = l0Class("ItemStopUseAfterEvent"); - -export const ItemStopUseAfterEventSignal = l0Class("ItemStopUseAfterEventSignal"); - -export const ItemStopUseOnAfterEvent = l0Class("ItemStopUseOnAfterEvent"); - -export const ItemStopUseOnAfterEventSignal = l0Class("ItemStopUseOnAfterEventSignal"); - -export const ItemType = l0Class("ItemType"); - -export const ItemTypes = l0Class("ItemTypes"); - -export const ItemUseAfterEvent = l0Class("ItemUseAfterEvent"); - -export const ItemUseAfterEventSignal = l0Class("ItemUseAfterEventSignal"); - -export const ItemUseBeforeEvent = l0Class("ItemUseBeforeEvent"); - -export const ItemUseBeforeEventSignal = l0Class("ItemUseBeforeEventSignal"); - -export const ItemUseOnEvent = l0Class("ItemUseOnEvent"); - -export const KilledByEntityCondition = l0Class("KilledByEntityCondition"); - -export const KilledByPlayerCondition = l0Class("KilledByPlayerCondition"); - -export const KilledByPlayerOrPetsCondition = l0Class("KilledByPlayerOrPetsCondition"); - -export const LeverActionAfterEvent = l0Class("LeverActionAfterEvent"); - -export const LeverActionAfterEventSignal = l0Class("LeverActionAfterEventSignal"); - -export const LinearSpline = l0Class("LinearSpline"); - -export const LiquidSettings = { - "ApplyWaterlogging": "ApplyWaterlogging", - "IgnoreWaterlogging": "IgnoreWaterlogging" -}; - -export const LiquidType = { - "Water": "Water" -}; - -export const ListBlockVolume = l0Class("ListBlockVolume"); - -export const LocationInUnloadedChunkError = l0Class("LocationInUnloadedChunkError"); - -export const LocationOutOfWorldBoundariesError = l0Class("LocationOutOfWorldBoundariesError"); - -export const LocationWaypoint = l0Class("LocationWaypoint"); - -export const LocatorBar = l0Class("LocatorBar"); - -export const LocatorBarError = l0Class("LocatorBarError"); - -export const LocatorBarErrorReason = { - "WaypointAlreadyExists": "WaypointAlreadyExists", - "WaypointLimitExceeded": "WaypointLimitExceeded", - "WaypointNotFound": "WaypointNotFound" -}; - -export const LootingEnchantFunction = l0Class("LootingEnchantFunction"); - -export const LootItem = l0Class("LootItem"); - -export const LootItemCondition = l0Class("LootItemCondition"); - -export const LootItemFunction = l0Class("LootItemFunction"); - -export const LootPool = l0Class("LootPool"); - -export const LootPoolEntry = l0Class("LootPoolEntry"); - -export const LootPoolTiers = l0Class("LootPoolTiers"); - -export const LootTable = l0Class("LootTable"); - -export const LootTableEntry = l0Class("LootTableEntry"); - -export const LootTableManager = l0Class("LootTableManager"); - -export const LootTableReference = l0Class("LootTableReference"); - -export const MatchToolCondition = l0Class("MatchToolCondition"); - -export const MemoryTier = { - "SuperLow": 0, - "Low": 1, - "Mid": 2, - "High": 3, - "SuperHigh": 4 -}; - -export const MessageReceiveAfterEvent = l0Class("MessageReceiveAfterEvent"); - -export const MolangVariableMap = l0Class("MolangVariableMap"); - -export const MoonPhase = { - "FullMoon": 0, - "WaningGibbous": 1, - "FirstQuarter": 2, - "WaningCrescent": 3, - "NewMoon": 4, - "WaxingCrescent": 5, - "LastQuarter": 6, - "WaxingGibbous": 7 -}; - -export const MoonPhaseCount = new Proxy({}, { - get(_t: object, prop: string | symbol) { - if (typeof prop === 'symbol') return undefined; - if (prop === 'then') return undefined; - throw new UnimplementedMinecraftApiError("MoonPhaseCount." + String(prop)); - }, -}); - -export const MovementType = { - "Immovable": "Immovable", - "Popped": "Popped", - "Push": "Push", - "PushPull": "PushPull" -}; - -export const NamespaceNameError = l0Class("NamespaceNameError"); - -export const NamespaceNameErrorReason = { - "DisallowedNamespace": "DisallowedNamespace", - "NoNamespace": "NoNamespace" -}; - -export const ObjectiveSortOrder = { - "Ascending": 0, - "Descending": 1 -}; - -export const PackSettingChangeAfterEvent = l0Class("PackSettingChangeAfterEvent"); - -export const PackSettingChangeAfterEventSignal = l0Class("PackSettingChangeAfterEventSignal"); - -export const PaletteColor = { - "White": 0, - "Orange": 1, - "Magenta": 2, - "LightBlue": 3, - "Yellow": 4, - "Lime": 5, - "Pink": 6, - "Gray": 7, - "Silver": 8, - "Cyan": 9, - "Purple": 10, - "Blue": 11, - "Brown": 12, - "Green": 13, - "Red": 14, - "Black": 15 -}; - -export const PassengerOfEntityCondition = l0Class("PassengerOfEntityCondition"); - -export const PistonActivateAfterEvent = l0Class("PistonActivateAfterEvent"); - -export const PistonActivateAfterEventSignal = l0Class("PistonActivateAfterEventSignal"); - -export const PlaceJigsawError = l0Class("PlaceJigsawError"); - -export const PlatformType = { - "Console": "Console", - "Desktop": "Desktop", - "Mobile": "Mobile" -}; - -export const PlayerAimAssist = l0Class("PlayerAimAssist"); - -export const PlayerBreakBlockAfterEvent = l0Class("PlayerBreakBlockAfterEvent"); - -export const PlayerBreakBlockAfterEventSignal = l0Class("PlayerBreakBlockAfterEventSignal"); - -export const PlayerBreakBlockBeforeEvent = l0Class("PlayerBreakBlockBeforeEvent"); - -export const PlayerBreakBlockBeforeEventSignal = l0Class("PlayerBreakBlockBeforeEventSignal"); - -export const PlayerButtonInputAfterEvent = l0Class("PlayerButtonInputAfterEvent"); - -export const PlayerButtonInputAfterEventSignal = l0Class("PlayerButtonInputAfterEventSignal"); - -export const PlayerCancelBreakingBlockAfterEvent = l0Class("PlayerCancelBreakingBlockAfterEvent"); - -export const PlayerCancelBreakingBlockAfterEventSignal = l0Class("PlayerCancelBreakingBlockAfterEventSignal"); - -export const PlayerCursorInventoryComponent = l0Class("PlayerCursorInventoryComponent"); - -export const PlayerDimensionChangeAfterEvent = l0Class("PlayerDimensionChangeAfterEvent"); - -export const PlayerDimensionChangeAfterEventSignal = l0Class("PlayerDimensionChangeAfterEventSignal"); - -export const PlayerEmoteAfterEvent = l0Class("PlayerEmoteAfterEvent"); - -export const PlayerEmoteAfterEventSignal = l0Class("PlayerEmoteAfterEventSignal"); - -export const PlayerGameModeChangeAfterEvent = l0Class("PlayerGameModeChangeAfterEvent"); - -export const PlayerGameModeChangeAfterEventSignal = l0Class("PlayerGameModeChangeAfterEventSignal"); - -export const PlayerGameModeChangeBeforeEvent = l0Class("PlayerGameModeChangeBeforeEvent"); - -export const PlayerGameModeChangeBeforeEventSignal = l0Class("PlayerGameModeChangeBeforeEventSignal"); - -export const PlayerHotbarSelectedSlotChangeAfterEvent = l0Class("PlayerHotbarSelectedSlotChangeAfterEvent"); - -export const PlayerHotbarSelectedSlotChangeAfterEventSignal = l0Class("PlayerHotbarSelectedSlotChangeAfterEventSignal"); - -export const PlayerInputModeChangeAfterEvent = l0Class("PlayerInputModeChangeAfterEvent"); - -export const PlayerInputModeChangeAfterEventSignal = l0Class("PlayerInputModeChangeAfterEventSignal"); - -export const PlayerInputPermissionCategoryChangeAfterEvent = l0Class("PlayerInputPermissionCategoryChangeAfterEvent"); - -export const PlayerInputPermissionCategoryChangeAfterEventSignal = l0Class("PlayerInputPermissionCategoryChangeAfterEventSignal"); - -export const PlayerInputPermissions = l0Class("PlayerInputPermissions"); - -export const PlayerInteractWithBlockAfterEvent = l0Class("PlayerInteractWithBlockAfterEvent"); - -export const PlayerInteractWithBlockAfterEventSignal = l0Class("PlayerInteractWithBlockAfterEventSignal"); - -export const PlayerInteractWithBlockBeforeEvent = l0Class("PlayerInteractWithBlockBeforeEvent"); - -export const PlayerInteractWithBlockBeforeEventSignal = l0Class("PlayerInteractWithBlockBeforeEventSignal"); - -export const PlayerInteractWithEntityAfterEvent = l0Class("PlayerInteractWithEntityAfterEvent"); - -export const PlayerInteractWithEntityAfterEventSignal = l0Class("PlayerInteractWithEntityAfterEventSignal"); - -export const PlayerInteractWithEntityBeforeEvent = l0Class("PlayerInteractWithEntityBeforeEvent"); - -export const PlayerInteractWithEntityBeforeEventSignal = l0Class("PlayerInteractWithEntityBeforeEventSignal"); - -export const PlayerInventoryItemChangeAfterEvent = l0Class("PlayerInventoryItemChangeAfterEvent"); - -export const PlayerInventoryItemChangeAfterEventSignal = l0Class("PlayerInventoryItemChangeAfterEventSignal"); - -export const PlayerInventoryType = { - "Hotbar": "Hotbar", - "Inventory": "Inventory" -}; - -export const PlayerJoinAfterEvent = l0Class("PlayerJoinAfterEvent"); - -export const PlayerJoinAfterEventSignal = l0Class("PlayerJoinAfterEventSignal"); - -export const PlayerLeaveAfterEvent = l0Class("PlayerLeaveAfterEvent"); - -export const PlayerLeaveAfterEventSignal = l0Class("PlayerLeaveAfterEventSignal"); - -export const PlayerLeaveBeforeEvent = l0Class("PlayerLeaveBeforeEvent"); - -export const PlayerLeaveBeforeEventSignal = l0Class("PlayerLeaveBeforeEventSignal"); - -export const PlayerPlaceBlockAfterEvent = l0Class("PlayerPlaceBlockAfterEvent"); - -export const PlayerPlaceBlockAfterEventSignal = l0Class("PlayerPlaceBlockAfterEventSignal"); - -export const PlayerPlaceBlockBeforeEvent = l0Class("PlayerPlaceBlockBeforeEvent"); - -export const PlayerPlaceBlockBeforeEventSignal = l0Class("PlayerPlaceBlockBeforeEventSignal"); - -export const PlayerSpawnAfterEvent = l0Class("PlayerSpawnAfterEvent"); - -export const PlayerSpawnAfterEventSignal = l0Class("PlayerSpawnAfterEventSignal"); - -export const PlayerSplitScreenSlot = { - "First": "First", - "Fourth": "Fourth", - "Second": "Second", - "Third": "Third" -}; - -export const PlayerStartBreakingBlockAfterEvent = l0Class("PlayerStartBreakingBlockAfterEvent"); - -export const PlayerStartBreakingBlockAfterEventSignal = l0Class("PlayerStartBreakingBlockAfterEventSignal"); - -export const PlayerSwingStartAfterEvent = l0Class("PlayerSwingStartAfterEvent"); - -export const PlayerSwingStartAfterEventSignal = l0Class("PlayerSwingStartAfterEventSignal"); - -export const PlayerUseNameTagAfterEvent = l0Class("PlayerUseNameTagAfterEvent"); - -export const PlayerUseNameTagAfterEventSignal = l0Class("PlayerUseNameTagAfterEventSignal"); - -export const PlayerWaypoint = l0Class("PlayerWaypoint"); - -export const PlayerWaypointsMode = { - "Everyone": "Everyone", - "Off": "Off" -}; - -export const PotionDeliveryType = l0Class("PotionDeliveryType"); - -export const PotionEffectType = l0Class("PotionEffectType"); - -export const Potions = l0Class("Potions"); - -export const PressurePlatePopAfterEvent = l0Class("PressurePlatePopAfterEvent"); - -export const PressurePlatePopAfterEventSignal = l0Class("PressurePlatePopAfterEventSignal"); - -export const PressurePlatePushAfterEvent = l0Class("PressurePlatePushAfterEvent"); - -export const PressurePlatePushAfterEventSignal = l0Class("PressurePlatePushAfterEventSignal"); - -export const PrimitiveShape = l0Class("PrimitiveShape"); - -export const PrimitiveShapeError = l0Class("PrimitiveShapeError"); - -export const PrimitiveShapesManager = l0Class("PrimitiveShapesManager"); - -export const ProjectileHitBlockAfterEvent = l0Class("ProjectileHitBlockAfterEvent"); - -export const ProjectileHitBlockAfterEventSignal = l0Class("ProjectileHitBlockAfterEventSignal"); - -export const ProjectileHitEntityAfterEvent = l0Class("ProjectileHitEntityAfterEvent"); - -export const ProjectileHitEntityAfterEventSignal = l0Class("ProjectileHitEntityAfterEventSignal"); - -export const RandomAuxValueFunction = l0Class("RandomAuxValueFunction"); - -export const RandomBlockStateFunction = l0Class("RandomBlockStateFunction"); - -export const RandomChanceCondition = l0Class("RandomChanceCondition"); - -export const RandomChanceWithLootingCondition = l0Class("RandomChanceWithLootingCondition"); - -export const RandomDifficultyChanceCondition = l0Class("RandomDifficultyChanceCondition"); - -export const RandomDyeFunction = l0Class("RandomDyeFunction"); - -export const RandomRegionalDifficultyChanceCondition = l0Class("RandomRegionalDifficultyChanceCondition"); - -export const RawMessageError = l0Class("RawMessageError"); - -export const Scoreboard = l0Class("Scoreboard"); - -export const ScoreboardIdentity = l0Class("ScoreboardIdentity"); - -export const ScoreboardIdentityType = { - "Entity": "Entity", - "FakePlayer": "FakePlayer", - "Player": "Player" -}; - -export const ScoreboardObjective = l0Class("ScoreboardObjective"); - -export const ScoreboardScoreInfo = l0Class("ScoreboardScoreInfo"); - -export const ScreenDisplay = l0Class("ScreenDisplay"); - -export const ScriptEventCommandMessageAfterEvent = l0Class("ScriptEventCommandMessageAfterEvent"); - -export const ScriptEventCommandMessageAfterEventSignal = l0Class("ScriptEventCommandMessageAfterEventSignal"); - -export const ScriptEventSource = { - "Block": "Block", - "Entity": "Entity", - "NPCDialogue": "NPCDialogue", - "Server": "Server" -}; - -export const Seat = l0Class("Seat"); - -export const ServerMessageAfterEventSignal = l0Class("ServerMessageAfterEventSignal"); - -export const SetArmorTrimFunction = l0Class("SetArmorTrimFunction"); - -export const SetBannerDetailsFunction = l0Class("SetBannerDetailsFunction"); - -export const SetBookContentsFunction = l0Class("SetBookContentsFunction"); - -export const SetDataFromColorIndexFunction = l0Class("SetDataFromColorIndexFunction"); - -export const SetItemCountFunction = l0Class("SetItemCountFunction"); - -export const SetItemDamageFunction = l0Class("SetItemDamageFunction"); - -export const SetItemDataFunction = l0Class("SetItemDataFunction"); - -export const SetItemLoreFunction = l0Class("SetItemLoreFunction"); - -export const SetItemNameFunction = l0Class("SetItemNameFunction"); - -export const SetOminousBottleFunction = l0Class("SetOminousBottleFunction"); - -export const SetPotionFunction = l0Class("SetPotionFunction"); - -export const SetSpawnEggFunction = l0Class("SetSpawnEggFunction"); - -export const SetStewEffectFunction = l0Class("SetStewEffectFunction"); - -export const ShutdownBeforeEventSignal = l0Class("ShutdownBeforeEventSignal"); - -export const ShutdownEvent = l0Class("ShutdownEvent"); - -export const SignSide = { - "Back": "Back", - "Front": "Front" -}; - -export const SmeltItemFunction = l0Class("SmeltItemFunction"); - -export const SoundCompletedAfterEvent = l0Class("SoundCompletedAfterEvent"); - -export const SoundCompletedAfterEventSignal = l0Class("SoundCompletedAfterEventSignal"); - -export const SoundDefinition = l0Class("SoundDefinition"); - -export const SoundDefinitionRegistry = l0Class("SoundDefinitionRegistry"); - -export const SoundDurationInfo = l0Class("SoundDurationInfo"); - -export const SoundInstance = l0Class("SoundInstance"); - -export const SpecificEnchantFunction = l0Class("SpecificEnchantFunction"); - -export const StartupBeforeEventSignal = l0Class("StartupBeforeEventSignal"); - -export const StartupEvent = l0Class("StartupEvent"); - -export const StickyType = { - "None": "None", - "Same": "Same" -}; - -export const Structure = l0Class("Structure"); - -export const StructureAnimationMode = { - "Blocks": "Blocks", - "Layers": "Layers", - "None": "None" -}; - -export const StructureManager = l0Class("StructureManager"); - -export const StructureMirrorAxis = { - "None": "None", - "X": "X", - "XZ": "XZ", - "Z": "Z" -}; - -export const StructureRotation = { - "None": "None", - "Rotate180": "Rotate180", - "Rotate270": "Rotate270", - "Rotate90": "Rotate90" -}; - -export const StructureSaveMode = { - "Memory": "Memory", - "World": "World" -}; - -export const System = l0Class("System"); - -export const SystemAfterEvents = l0Class("SystemAfterEvents"); - -export const SystemBeforeEvents = l0Class("SystemBeforeEvents"); - -export const SystemInfo = l0Class("SystemInfo"); - -export const TargetBlockHitAfterEvent = l0Class("TargetBlockHitAfterEvent"); - -export const TargetBlockHitAfterEventSignal = l0Class("TargetBlockHitAfterEventSignal"); - -export const TextPrimitive = l0Class("TextPrimitive"); - -export const TickingAreaError = l0Class("TickingAreaError"); - -export const TickingAreaErrorReason = { - "IdentifierAlreadyExists": "IdentifierAlreadyExists" -}; - -export const TickingAreaManager = l0Class("TickingAreaManager"); - -export const TicksPerDay = new Proxy({}, { - get(_t: object, prop: string | symbol) { - if (typeof prop === 'symbol') return undefined; - if (prop === 'then') return undefined; - throw new UnimplementedMinecraftApiError("TicksPerDay." + String(prop)); - }, -}); - -export const TicksPerSecond = new Proxy({}, { - get(_t: object, prop: string | symbol) { - if (typeof prop === 'symbol') return undefined; - if (prop === 'then') return undefined; - throw new UnimplementedMinecraftApiError("TicksPerSecond." + String(prop)); - }, -}); - -export const TimeOfDay = { - "Day": 1000, - "Noon": 6000, - "Sunset": 12000, - "Night": 13000, - "Midnight": 18000, - "Sunrise": 23000 -}; - -export const TintMethod = { - "BirchFoliage": "BirchFoliage", - "DefaultFoliage": "DefaultFoliage", - "DryFoliage": "DryFoliage", - "EvergreenFoliage": "EvergreenFoliage", - "Grass": "Grass", - "None": "None", - "Water": "Water" -}; - -export const Trigger = l0Class("Trigger"); - -export const TripWireTripAfterEvent = l0Class("TripWireTripAfterEvent"); - -export const TripWireTripAfterEventSignal = l0Class("TripWireTripAfterEventSignal"); - -export const UnloadedChunksError = l0Class("UnloadedChunksError"); - -export const WatchdogTerminateBeforeEvent = l0Class("WatchdogTerminateBeforeEvent"); - -export const WatchdogTerminateBeforeEventSignal = l0Class("WatchdogTerminateBeforeEventSignal"); - -export const WatchdogTerminateReason = { - "Hang": "Hang", - "StackOverflow": "StackOverflow" -}; - -export const Waypoint = l0Class("Waypoint"); - -export const WaypointTexture = { - "Circle": "minecraft:circle", - "SmallSquare": "minecraft:small_square", - "SmallStar": "minecraft:small_star", - "Square": "minecraft:square" -}; - -export const WeatherChangeAfterEvent = l0Class("WeatherChangeAfterEvent"); - -export const WeatherChangeAfterEventSignal = l0Class("WeatherChangeAfterEventSignal"); - -export const WeatherChangeBeforeEvent = l0Class("WeatherChangeBeforeEvent"); - -export const WeatherChangeBeforeEventSignal = l0Class("WeatherChangeBeforeEventSignal"); - -export const WeatherType = { - "Clear": "Clear", - "Rain": "Rain", - "Thunder": "Thunder" -}; - -export const World = l0Class("World"); - -export const WorldAfterEvents = l0Class("WorldAfterEvents"); - -export const WorldBeforeEvents = l0Class("WorldBeforeEvents"); - -export const WorldLoadAfterEvent = l0Class("WorldLoadAfterEvent"); - -export const WorldLoadAfterEventSignal = l0Class("WorldLoadAfterEventSignal"); - -export const __sfmcL0ExportNames = ["AimAssistCategory","AimAssistCategorySettings","AimAssistPreset","AimAssistPresetSettings","AimAssistRegistry","AimAssistTargetMode","BannerPattern","BiomeType","BiomeTypes","Block","BlockBoundingBoxUtils","BlockComponent","BlockComponentBlockBreakEvent","BlockComponentBlockStateChangeEvent","BlockComponentEntityEvent","BlockComponentEntityFallOnEvent","BlockComponentOnPlaceEvent","BlockComponentPlayerBreakEvent","BlockComponentPlayerInteractEvent","BlockComponentPlayerPlaceBeforeEvent","BlockComponentRandomTickEvent","BlockComponentRedstoneUpdateEvent","BlockComponentRegistry","BlockComponentStepOffEvent","BlockComponentStepOnEvent","BlockComponentTickEvent","BlockContainerClosedAfterEvent","BlockContainerClosedAfterEventSignal","BlockContainerOpenedAfterEvent","BlockContainerOpenedAfterEventSignal","BlockCustomComponentAlreadyRegisteredError","BlockCustomComponentInstance","BlockCustomComponentReloadNewComponentError","BlockCustomComponentReloadNewEventError","BlockCustomComponentReloadVersionError","BlockDynamicPropertiesComponent","BlockEvent","BlockExplodeAfterEvent","BlockExplodeAfterEventSignal","BlockFluidContainerComponent","BlockInstrumentComponent","BlockInventoryComponent","BlockLocationIterator","BlockMapColorComponent","BlockMovableComponent","BlockPistonComponent","BlockPistonState","BlockPrecipitationInteractionsComponent","BlockRecordPlayerComponent","BlockRedstoneProducerComponent","BlockSignComponent","BlockStates","BlockStateType","BlockType","BlockTypes","BlockVolume","BlockVolumeBase","BlockVolumeIntersection","BookError","BookErrorReason","BookPageContentError","ButtonPushAfterEvent","ButtonPushAfterEventSignal","ButtonState","Camera","CameraShakeType","CarryOverBlockEntityDataFunction","CatmullRomSpline","ChatSendAfterEvent","ChatSendAfterEventSignal","ChatSendBeforeEvent","ChatSendBeforeEventSignal","ClientSystemInfo","CloneMode","CommandError","CommandPermissionLevel","CommandResult","Component","CompoundBlockVolume","CompoundBlockVolumeAction","CompoundBlockVolumePositionRelativity","Container","ContainerRulesError","ContainerRulesErrorReason","ContainerSlot","ControlScheme","CustomCommandError","CustomCommandErrorReason","CustomCommandOrigin","CustomCommandParamType","CustomCommandRegistry","CustomCommandSource","CustomCommandStatus","CustomComponentInvalidRegistryError","CustomComponentNameError","CustomComponentNameErrorReason","CustomComponentParameters","CustomDimensionAlreadyRegisteredError","CustomDimensionInvalidRegistryError","CustomDimensionNameError","CustomDimensionReloadNewDimensionError","DamagedByEntityCondition","DataDrivenEntityTriggerAfterEvent","DataDrivenEntityTriggerAfterEventSignal","Difficulty","DimensionRegistry","DimensionType","DimensionTypes","Direction","DisplaySlotId","DyeColor","EasingType","EffectAddAfterEvent","EffectAddAfterEventSignal","EffectAddBeforeEvent","EffectAddBeforeEventSignal","EffectType","EffectTypes","EmptyLootItem","EnchantInfo","EnchantmentLevelOutOfBoundsError","EnchantmentSlot","EnchantmentType","EnchantmentTypeNotCompatibleError","EnchantmentTypes","EnchantmentTypeUnknownIdError","EnchantRandomEquipmentFunction","EnchantRandomlyFunction","EnchantWithLevelsFunction","EntityAddRiderComponent","EntityAgeableComponent","EntityAttachPoint","EntityAttributeComponent","EntityBaseMovementComponent","EntityBreathableComponent","EntityCanClimbComponent","EntityCanFlyComponent","EntityCanPowerJumpComponent","EntityColor2Component","EntityColorComponent","EntityComponent","EntityComponentTypes","EntityContainerClosedAfterEvent","EntityContainerClosedAfterEventSignal","EntityContainerOpenedAfterEvent","EntityContainerOpenedAfterEventSignal","EntityDamageCause","EntityDefinitionFeedItem","EntityDieAfterEvent","EntityDieAfterEventSignal","EntityEnderInventoryComponent","EntityEquippableComponent","EntityExhaustionComponent","EntityFireImmuneComponent","EntityFloatsInLiquidComponent","EntityFlyingSpeedComponent","EntityFrictionModifierComponent","EntityHasMarkVariantCondition","EntityHasVariantCondition","EntityHealableComponent","EntityHealAfterEvent","EntityHealAfterEventSignal","EntityHealBeforeEvent","EntityHealBeforeEventSignal","EntityHealCause","EntityHealSource","EntityHealthChangedAfterEvent","EntityHealthChangedAfterEventSignal","EntityHitBlockAfterEvent","EntityHitBlockAfterEventSignal","EntityHitEntityAfterEvent","EntityHitEntityAfterEventSignal","EntityHungerComponent","EntityHurtAfterEvent","EntityHurtAfterEventSignal","EntityHurtBeforeEvent","EntityHurtBeforeEventSignal","EntityIsBabyComponent","EntityIsChargedComponent","EntityIsChestedComponent","EntityIsDyeableComponent","EntityIsHiddenWhenInvisibleComponent","EntityIsIgnitedComponent","EntityIsIllagerCaptainComponent","EntityIsSaddledComponent","EntityIsShakingComponent","EntityIsShearedComponent","EntityIsStackableComponent","EntityIsStunnedComponent","EntityIsTamedComponent","EntityItemComponent","EntityItemDropAfterEvent","EntityItemDropAfterEventSignal","EntityItemPickupAfterEvent","EntityItemPickupAfterEventSignal","EntityItemPickupBeforeEvent","EntityItemPickupBeforeEventSignal","EntityKilledCondition","EntityLavaMovementComponent","EntityLeashableComponent","EntityLoadAfterEvent","EntityLoadAfterEventSignal","EntityMarkVariantComponent","EntityMovementAmphibiousComponent","EntityMovementBasicComponent","EntityMovementComponent","EntityMovementFlyComponent","EntityMovementGenericComponent","EntityMovementGlideComponent","EntityMovementHoverComponent","EntityMovementJumpComponent","EntityMovementSkipComponent","EntityMovementSwayComponent","EntityNavigationClimbComponent","EntityNavigationComponent","EntityNavigationFloatComponent","EntityNavigationFlyComponent","EntityNavigationGenericComponent","EntityNavigationHoverComponent","EntityNavigationWalkComponent","EntityNpcComponent","EntityOnFireComponent","EntityProjectileComponent","EntityPushThroughComponent","EntityRemoveAfterEvent","EntityRemoveAfterEventSignal","EntityRemoveBeforeEvent","EntityRemoveBeforeEventSignal","EntityRideableComponent","EntityRidingComponent","EntitySaturationComponent","EntityScaleComponent","EntitySkinIdComponent","EntitySpawnAfterEvent","EntitySpawnAfterEventSignal","EntitySpawnError","EntityStartSneakingAfterEvent","EntityStartSneakingAfterEventSignal","EntityStopSneakingAfterEvent","EntityStopSneakingAfterEventSignal","EntityStrengthComponent","EntitySwingSource","EntityTameableComponent","EntityTamedAfterEvent","EntityTamedAfterEventSignal","EntityTamedBeforeEvent","EntityTamedBeforeEventSignal","EntityTameMountComponent","EntityType","EntityTypeFamilyComponent","EntityTypes","EntityUnderwaterMovementComponent","EntityUpgradeAfterEvent","EntityUpgradeAfterEventSignal","EntityVariantComponent","EntityWantsJockeyComponent","EntityWaypoint","EquipmentSlot","ExplorationMapFunction","ExplosionAfterEvent","ExplosionAfterEventSignal","ExplosionBeforeEvent","ExplosionBeforeEventSignal","ExplosionDecayFunction","FeedItem","FeedItemEffect","FillContainerFunction","FluidContainer","FluidType","FogSettings","FogSettingsError","GameRule","GameRuleChangeAfterEvent","GameRuleChangeAfterEventSignal","GameRules","GraphicsMode","HeldItemOption","HudElement","HudElementsCount","HudVisibility","HudVisibilityCount","InputButton","InputInfo","InputMode","InputPermissionCategory","InvalidBlockComponentError","InvalidContainerError","InvalidContainerSlotError","InvalidEntityComponentError","InvalidEntityError","InvalidItemStackError","InvalidIteratorError","InvalidPotionDeliveryTypeError","InvalidPotionEffectTypeError","InvalidStructureError","InvalidWaypointError","InvalidWaypointTextureSelectorError","IsBabyCondition","ISerializable","ItemBlockDynamicPropertiesComponent","ItemBookComponent","ItemCompleteUseAfterEvent","ItemCompleteUseAfterEventSignal","ItemCompleteUseEvent","ItemComponent","ItemComponentBeforeDurabilityDamageEvent","ItemComponentCompleteUseEvent","ItemComponentConsumeEvent","ItemComponentHitEntityEvent","ItemComponentMineBlockEvent","ItemComponentRegistry","ItemComponentTypes","ItemComponentUseEvent","ItemComponentUseOnEvent","ItemCompostableComponent","ItemCooldownComponent","ItemCustomComponentAlreadyRegisteredError","ItemCustomComponentInstance","ItemCustomComponentReloadNewComponentError","ItemCustomComponentReloadNewEventError","ItemCustomComponentReloadVersionError","ItemDurabilityComponent","ItemDyeableComponent","ItemEnchantableComponent","ItemFoodComponent","ItemInventoryComponent","ItemLockMode","ItemPotionComponent","ItemReleaseUseAfterEvent","ItemReleaseUseAfterEventSignal","ItemStartUseAfterEvent","ItemStartUseAfterEventSignal","ItemStartUseOnAfterEvent","ItemStartUseOnAfterEventSignal","ItemStopUseAfterEvent","ItemStopUseAfterEventSignal","ItemStopUseOnAfterEvent","ItemStopUseOnAfterEventSignal","ItemType","ItemTypes","ItemUseAfterEvent","ItemUseAfterEventSignal","ItemUseBeforeEvent","ItemUseBeforeEventSignal","ItemUseOnEvent","KilledByEntityCondition","KilledByPlayerCondition","KilledByPlayerOrPetsCondition","LeverActionAfterEvent","LeverActionAfterEventSignal","LinearSpline","LiquidSettings","LiquidType","ListBlockVolume","LocationInUnloadedChunkError","LocationOutOfWorldBoundariesError","LocationWaypoint","LocatorBar","LocatorBarError","LocatorBarErrorReason","LootingEnchantFunction","LootItem","LootItemCondition","LootItemFunction","LootPool","LootPoolEntry","LootPoolTiers","LootTable","LootTableEntry","LootTableManager","LootTableReference","MatchToolCondition","MemoryTier","MessageReceiveAfterEvent","MolangVariableMap","MoonPhase","MoonPhaseCount","MovementType","NamespaceNameError","NamespaceNameErrorReason","ObjectiveSortOrder","PackSettingChangeAfterEvent","PackSettingChangeAfterEventSignal","PaletteColor","PassengerOfEntityCondition","PistonActivateAfterEvent","PistonActivateAfterEventSignal","PlaceJigsawError","PlatformType","PlayerAimAssist","PlayerBreakBlockAfterEvent","PlayerBreakBlockAfterEventSignal","PlayerBreakBlockBeforeEvent","PlayerBreakBlockBeforeEventSignal","PlayerButtonInputAfterEvent","PlayerButtonInputAfterEventSignal","PlayerCancelBreakingBlockAfterEvent","PlayerCancelBreakingBlockAfterEventSignal","PlayerCursorInventoryComponent","PlayerDimensionChangeAfterEvent","PlayerDimensionChangeAfterEventSignal","PlayerEmoteAfterEvent","PlayerEmoteAfterEventSignal","PlayerGameModeChangeAfterEvent","PlayerGameModeChangeAfterEventSignal","PlayerGameModeChangeBeforeEvent","PlayerGameModeChangeBeforeEventSignal","PlayerHotbarSelectedSlotChangeAfterEvent","PlayerHotbarSelectedSlotChangeAfterEventSignal","PlayerInputModeChangeAfterEvent","PlayerInputModeChangeAfterEventSignal","PlayerInputPermissionCategoryChangeAfterEvent","PlayerInputPermissionCategoryChangeAfterEventSignal","PlayerInputPermissions","PlayerInteractWithBlockAfterEvent","PlayerInteractWithBlockAfterEventSignal","PlayerInteractWithBlockBeforeEvent","PlayerInteractWithBlockBeforeEventSignal","PlayerInteractWithEntityAfterEvent","PlayerInteractWithEntityAfterEventSignal","PlayerInteractWithEntityBeforeEvent","PlayerInteractWithEntityBeforeEventSignal","PlayerInventoryItemChangeAfterEvent","PlayerInventoryItemChangeAfterEventSignal","PlayerInventoryType","PlayerJoinAfterEvent","PlayerJoinAfterEventSignal","PlayerLeaveAfterEvent","PlayerLeaveAfterEventSignal","PlayerLeaveBeforeEvent","PlayerLeaveBeforeEventSignal","PlayerPlaceBlockAfterEvent","PlayerPlaceBlockAfterEventSignal","PlayerPlaceBlockBeforeEvent","PlayerPlaceBlockBeforeEventSignal","PlayerSpawnAfterEvent","PlayerSpawnAfterEventSignal","PlayerSplitScreenSlot","PlayerStartBreakingBlockAfterEvent","PlayerStartBreakingBlockAfterEventSignal","PlayerSwingStartAfterEvent","PlayerSwingStartAfterEventSignal","PlayerUseNameTagAfterEvent","PlayerUseNameTagAfterEventSignal","PlayerWaypoint","PlayerWaypointsMode","PotionDeliveryType","PotionEffectType","Potions","PressurePlatePopAfterEvent","PressurePlatePopAfterEventSignal","PressurePlatePushAfterEvent","PressurePlatePushAfterEventSignal","PrimitiveShape","PrimitiveShapeError","PrimitiveShapesManager","ProjectileHitBlockAfterEvent","ProjectileHitBlockAfterEventSignal","ProjectileHitEntityAfterEvent","ProjectileHitEntityAfterEventSignal","RandomAuxValueFunction","RandomBlockStateFunction","RandomChanceCondition","RandomChanceWithLootingCondition","RandomDifficultyChanceCondition","RandomDyeFunction","RandomRegionalDifficultyChanceCondition","RawMessageError","Scoreboard","ScoreboardIdentity","ScoreboardIdentityType","ScoreboardObjective","ScoreboardScoreInfo","ScreenDisplay","ScriptEventCommandMessageAfterEvent","ScriptEventCommandMessageAfterEventSignal","ScriptEventSource","Seat","ServerMessageAfterEventSignal","SetArmorTrimFunction","SetBannerDetailsFunction","SetBookContentsFunction","SetDataFromColorIndexFunction","SetItemCountFunction","SetItemDamageFunction","SetItemDataFunction","SetItemLoreFunction","SetItemNameFunction","SetOminousBottleFunction","SetPotionFunction","SetSpawnEggFunction","SetStewEffectFunction","ShutdownBeforeEventSignal","ShutdownEvent","SignSide","SmeltItemFunction","SoundCompletedAfterEvent","SoundCompletedAfterEventSignal","SoundDefinition","SoundDefinitionRegistry","SoundDurationInfo","SoundInstance","SpecificEnchantFunction","StartupBeforeEventSignal","StartupEvent","StickyType","Structure","StructureAnimationMode","StructureManager","StructureMirrorAxis","StructureRotation","StructureSaveMode","System","SystemAfterEvents","SystemBeforeEvents","SystemInfo","TargetBlockHitAfterEvent","TargetBlockHitAfterEventSignal","TextPrimitive","TickingAreaError","TickingAreaErrorReason","TickingAreaManager","TicksPerDay","TicksPerSecond","TimeOfDay","TintMethod","Trigger","TripWireTripAfterEvent","TripWireTripAfterEventSignal","UnloadedChunksError","WatchdogTerminateBeforeEvent","WatchdogTerminateBeforeEventSignal","WatchdogTerminateReason","Waypoint","WaypointTexture","WeatherChangeAfterEvent","WeatherChangeAfterEventSignal","WeatherChangeBeforeEvent","WeatherChangeBeforeEventSignal","WeatherType","World","WorldAfterEvents","WorldBeforeEvents","WorldLoadAfterEvent","WorldLoadAfterEventSignal"]; diff --git a/_sandbox_archive/testing/engine/generated/server-ui-l0.ts b/_sandbox_archive/testing/engine/generated/server-ui-l0.ts deleted file mode 100644 index 9cefb4d9..00000000 --- a/_sandbox_archive/testing/engine/generated/server-ui-l0.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * 由 scripts/gen-mc-fake.mjs 生成 — 勿手改。 - * L0:可 import;未实现成员硬失败。 - */ -import { UnimplementedMinecraftApiError } from "../unimplemented-error.js"; - -function l0Class(apiPath: string) { - return class { - constructor(..._args: unknown[]) { - return new Proxy(this, { - get(target: object, prop: string | symbol, receiver: unknown) { - if (typeof prop === 'symbol') return Reflect.get(target, prop, receiver); - if (prop === 'then') return undefined; - if (prop === 'constructor') return Reflect.get(target, prop, receiver); - throw new UnimplementedMinecraftApiError(`${apiPath}.${String(prop)}`); - }, - }); - } - }; -} - -export const ActionFormResponse = l0Class("ActionFormResponse"); - -export const FormRejectError = l0Class("FormRejectError"); - -export const FormRejectReason = { - "MalformedResponse": "MalformedResponse", - "PlayerQuit": "PlayerQuit", - "ServerShutdown": "ServerShutdown" -}; - -export const FormResponse = l0Class("FormResponse"); - -export const FormVisibilityError = l0Class("FormVisibilityError"); - -export const FormVisibilityErrorReason = { - "AlreadyShowing": "AlreadyShowing", - "NotShowing": "NotShowing" -}; - -export const InvalidFormError = l0Class("InvalidFormError"); - -export const InvalidFormModificationError = l0Class("InvalidFormModificationError"); - -export const InvalidObservableError = l0Class("InvalidObservableError"); - -export const MessageFormResponse = l0Class("MessageFormResponse"); - -export const ModalFormResponse = l0Class("ModalFormResponse"); - -export const PlayerLeftError = l0Class("PlayerLeftError"); - -export const TextFilteringError = { - "DisabledByPlayer": "DisabledByPlayer", - "TextProcessorServiceUnreachable": "TextProcessorServiceUnreachable", - "Unknown": "Unknown" -}; - -export const UIManager = l0Class("UIManager"); - -export const __sfmcL0ExportNames = ["ActionFormResponse","FormRejectError","FormRejectReason","FormResponse","FormVisibilityError","FormVisibilityErrorReason","InvalidFormError","InvalidFormModificationError","InvalidObservableError","MessageFormResponse","ModalFormResponse","PlayerLeftError","TextFilteringError","UIManager"]; diff --git a/_sandbox_archive/testing/engine/l2-skip.json b/_sandbox_archive/testing/engine/l2-skip.json deleted file mode 100644 index a8c1611b..00000000 --- a/_sandbox_archive/testing/engine/l2-skip.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "description": "实现面跳过:不做 L2 / Call 不主推。L0 名面仍保留并可硬失败。≠ allowlist(不从声明面删除)。原则:只读遥测、客户端态、沙箱无断言价值、不能设的无意义 getter。", - "entries": [ - "Player.getPing", - "Player.getAimAssist", - "Player.getControlScheme", - "Player.clientSystemInfo", - "Player.graphicsMode", - "Player.inputInfo", - "Player.isEmoting", - "Player.isFlying", - "Player.isGliding", - "Player.isJumping", - "Player.clearPropertyOverridesForEntity", - "Player.setPropertyOverrideForEntity", - "Entity.isSleeping", - "Entity.isSprinting" - ] -} diff --git a/_sandbox_archive/testing/engine/mc-bridge-admin.ts b/_sandbox_archive/testing/engine/mc-bridge-admin.ts deleted file mode 100644 index 7dfdb195..00000000 --- a/_sandbox_archive/testing/engine/mc-bridge-admin.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** @minecraft/server-admin 薄 stub(含 AllowList / kick 供模块单测)。 */ - -export class SecretString { - constructor(private value: string) {} - toString() { - return this.value; - } -} - -export const secrets = { - get(_name: string): SecretString | undefined { - return undefined; - }, -}; - -export const variables = { - get(_name: string): string | undefined { - return undefined; - }, -}; - -const allowListStore = new Set(); - -function entryName(player: string | { name?: string }): string { - if (typeof player === "string") return player; - return String(player.name ?? ""); -} - -export class AllowList { - get enabled(): boolean { - return true; - } - get entries(): Array<{ name: string }> { - return [...allowListStore].map((name) => ({ name })); - } - add(player: string | { name?: string }): void { - const name = entryName(player); - if (name) allowListStore.add(name); - } - remove(player: string | { name?: string }): void { - const name = entryName(player); - if (name) allowListStore.delete(name); - } - contains(player: string | { name?: string }): boolean { - const name = entryName(player); - return name ? allowListStore.has(name) : false; - } - reloadFile(): void { - /* no-op in stub */ - } - clear(): void { - allowListStore.clear(); - } -} - -export const dedicatedServer: { allowList: AllowList } | undefined = { - allowList: new AllowList(), -}; - -export function kickPlayer(_player: unknown, _reason?: string): void { - /* stub */ -} - -export function opPlayer(_player: unknown): void { - /* stub */ -} - -export function deopPlayer(_player: unknown): void { - /* stub */ -} - -export function transferPlayer(_player: unknown, _opts: unknown): void { - /* stub */ -} - -export const beforeEvents = { - asyncPlayerJoin: { - subscribe(_cb: unknown): void { - /* stub */ - }, - unsubscribe(_cb: unknown): void { - /* stub */ - }, - }, -}; diff --git a/_sandbox_archive/testing/engine/mc-bridge-diagnostics.ts b/_sandbox_archive/testing/engine/mc-bridge-diagnostics.ts deleted file mode 100644 index 73305a23..00000000 --- a/_sandbox_archive/testing/engine/mc-bridge-diagnostics.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** @minecraft/diagnostics 薄 stub。 */ -export const SentryEventLevel = { - Error: "error", - Warning: "warning", - Info: "info", - Debug: "debug", -} as const; - -export const sentry = { - init() {}, - captureException() {}, - captureEvent() {}, - addBreadcrumb() {}, -}; diff --git a/_sandbox_archive/testing/engine/mc-bridge-net.ts b/_sandbox_archive/testing/engine/mc-bridge-net.ts deleted file mode 100644 index 1c02680e..00000000 --- a/_sandbox_archive/testing/engine/mc-bridge-net.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** @minecraft/server-net 薄 stub(真 HTTP 留给 smoke-modules)。 */ -export const HttpRequestMethod = { - Get: "Get", - Post: "Post", - Put: "Put", - Delete: "Delete", - Head: "Head", -}; - -export class HttpHeader { - constructor( - public key: string, - public value: string - ) {} -} - -export class HttpRequest { - method = HttpRequestMethod.Get; - headers: HttpHeader[] = []; - body = ""; - constructor(public uri: string) {} - setMethod(m: string) { - this.method = m; - return this; - } - setBody(b: string) { - this.body = b; - return this; - } - setHeaders(h: HttpHeader[]) { - this.headers = h; - return this; - } -} - -export const http = { - async request(_req: HttpRequest) { - return { status: 200, body: "{}" }; - }, -}; diff --git a/_sandbox_archive/testing/engine/mc-bridge-server.ts b/_sandbox_archive/testing/engine/mc-bridge-server.ts deleted file mode 100644 index 82dff305..00000000 --- a/_sandbox_archive/testing/engine/mc-bridge-server.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * @minecraft/server 桥:手写语义 + L0 生成大范围导出。 - */ - -import { - getSystem, - getWorld, - GameMode, - PlayerPermissionLevel, - createServerExports, -} from "./runtime.js"; -import { UnimplementedMinecraftApiError } from "./unimplemented-error.js"; -import * as L0 from "./generated/server-l0.js"; - -const base = createServerExports(); - -function bindGet(getter: () => T): T { - return new Proxy({} as T, { - get(_t, prop, _receiver) { - const target = getter(); - const v = Reflect.get(target, prop, target); - return typeof v === "function" ? (v as (...a: unknown[]) => unknown).bind(target) : v; - }, - has(_t, prop) { - return Reflect.has(getter(), prop); - }, - ownKeys() { - return Reflect.ownKeys(getter()); - }, - getOwnPropertyDescriptor(_t, prop) { - const desc = Reflect.getOwnPropertyDescriptor(getter(), prop); - if (!desc) return undefined; - return { ...desc, configurable: true }; - }, - }); -} - -export const world = bindGet(getWorld); -export const system = bindGet(getSystem); - -export const Player = base.Player; -export { PlayerPermissionLevel, GameMode }; -export const ItemStack = base.ItemStack; -export const Entity = base.Entity; -export const BlockComponentTypes = base.BlockComponentTypes; -export const BlockPermutation = base.BlockPermutation; -export const Dimension = base.Dimension; -export const EntityInventoryComponent = base.EntityInventoryComponent; -export const EntityHealthComponent = base.EntityHealthComponent; -export const Effect = base.Effect; -export const EntityInitializationCause = base.EntityInitializationCause; - -export * from "./generated/server-l0.js"; - -const hand: Record = { - world, - system, - Player, - PlayerPermissionLevel, - GameMode, - ItemStack, - Entity, - BlockComponentTypes, - BlockPermutation, - Dimension, - EntityInventoryComponent, - EntityHealthComponent, - Effect, - EntityInitializationCause, -}; - -export default new Proxy(hand, { - get(t, prop) { - if (prop === "__esModule") return true; - if (typeof prop === "symbol") return undefined; - if (Object.prototype.hasOwnProperty.call(t, prop)) return t[prop]; - const fromL0 = (L0 as Record)[prop]; - if (fromL0 !== undefined) return fromL0; - throw new UnimplementedMinecraftApiError(`@minecraft/server.${String(prop)}`); - }, - has(t, prop) { - if (typeof prop === "symbol") return false; - return Object.prototype.hasOwnProperty.call(t, prop) || prop in (L0 as object); - }, -}); diff --git a/_sandbox_archive/testing/engine/mc-bridge-ui.ts b/_sandbox_archive/testing/engine/mc-bridge-ui.ts deleted file mode 100644 index 8ce5577d..00000000 --- a/_sandbox_archive/testing/engine/mc-bridge-ui.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * @minecraft/server-ui 桥:手写 L2 + L0 生成大范围导出。 - */ - -import { UnimplementedMinecraftApiError } from "./unimplemented-error.js"; -import * as L0 from "./generated/server-ui-l0.js"; -import { - ActionFormData, - MessageFormData, - ModalFormData, - CustomForm, - MessageBox, - FormCancelationReason, - DataDrivenScreenClosedReason, - ObservableBoolean, - ObservableNumber, - ObservableString, - ObservableUIRawMessage, - uiManager, -} from "./overrides/ui.js"; - -export { - ActionFormData, - MessageFormData, - ModalFormData, - CustomForm, - MessageBox, - FormCancelationReason, - DataDrivenScreenClosedReason, - ObservableBoolean, - ObservableNumber, - ObservableString, - ObservableUIRawMessage, - uiManager, -}; - -export * from "./generated/server-ui-l0.js"; - -const hand: Record = { - ActionFormData, - MessageFormData, - ModalFormData, - CustomForm, - MessageBox, - FormCancelationReason, - DataDrivenScreenClosedReason, - ObservableBoolean, - ObservableNumber, - ObservableString, - ObservableUIRawMessage, - uiManager, -}; - -export default new Proxy(hand, { - get(t, prop) { - if (prop === "__esModule") return true; - if (typeof prop === "symbol") return undefined; - if (Object.prototype.hasOwnProperty.call(t, prop)) return t[prop]; - const fromL0 = (L0 as Record)[prop]; - if (fromL0 !== undefined) return fromL0; - throw new UnimplementedMinecraftApiError(`@minecraft/server-ui.${String(prop)}`); - }, - has(t, prop) { - if (typeof prop === "symbol") return false; - return Object.prototype.hasOwnProperty.call(t, prop) || prop in (L0 as object); - }, -}); diff --git a/_sandbox_archive/testing/engine/overrides/command.ts b/_sandbox_archive/testing/engine/overrides/command.ts deleted file mode 100644 index 77b63b50..00000000 --- a/_sandbox_archive/testing/engine/overrides/command.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * 薄 L2:runCommand 记录命令串并返回 successCount。 - * 可选副作用:gamemode;give/clear → 物品栏;ability → 能力状态袋(area/fly)。 - * 解析不了的保持记录-only,不抛错。 - */ - -import { ItemStack, type FakeContainer } from "./inventory.js"; - -export type FakeCommandResult = { - readonly successCount: number; -}; - -export function createCommandResult(successCount = 1): FakeCommandResult { - return { successCount }; -} - -/** 归一化 GameMode 字面量;无法识别则返回 undefined。 */ -export function normalizeGameMode(raw: unknown): string | undefined { - if (raw == null) return undefined; - const s = String(raw).trim(); - const lower = s.toLowerCase(); - const aliases: Record = { - survival: "Survival", - s: "Survival", - "0": "Survival", - creative: "Creative", - c: "Creative", - "1": "Creative", - adventure: "Adventure", - a: "Adventure", - "2": "Adventure", - spectator: "Spectator", - sp: "Spectator", - "3": "Spectator", - }; - if (aliases[lower]) return aliases[lower]; - // 已是 pin 枚举字面量 - if (s === "Survival" || s === "Creative" || s === "Adventure" || s === "Spectator") return s; - return undefined; -} - -/** 解析命令参数:支持 "quoted name" / 'quoted' / 裸 token。 */ -function nextToken(rest: string): { token: string; rest: string } | undefined { - const s = rest.trimStart(); - if (!s) return undefined; - if (s[0] === '"' || s[0] === "'") { - const q = s[0]; - const end = s.indexOf(q, 1); - if (end < 0) return undefined; - return { token: s.slice(1, end), rest: s.slice(end + 1) }; - } - const m = /^(\S+)(.*)$/.exec(s); - if (!m) return undefined; - return { token: m[1]!, rest: m[2] ?? "" }; -} - -function normalizeItemTypeId(id: string): string { - const s = String(id ?? "").trim(); - if (!s) return ""; - return s.includes(":") ? s : `minecraft:${s}`; -} - -/** 目标是否指向本实体(@s/@p/@a 或名字匹配)。 */ -export function isSelfCommandTarget(target: string, selfName?: string): boolean { - const t = String(target ?? "").trim(); - if (!t) return false; - const lower = t.toLowerCase(); - if (lower === "@s" || lower === "@p" || lower === "@a" || lower === "@e") return true; - if (selfName != null && t === selfName) return true; - return false; -} - -export type ParsedGiveClear = - | { kind: "give"; itemId: string; amount: number; data: number } - | { kind: "clear"; itemId: string; data: number; maxCount: number }; - -/** - * 解析 `give [amount] [data]` / `clear [data] [maxCount]`。 - * 不匹配则返回 undefined(调用方仍只记录)。 - */ -export function parseGiveOrClear(cmd: string): ParsedGiveClear | undefined { - const give = /^give\s+/i.exec(cmd); - if (give) { - let rest = cmd.slice(give[0].length); - const targetTok = nextToken(rest); - if (!targetTok) return undefined; - rest = targetTok.rest; - const itemTok = nextToken(rest); - if (!itemTok) return undefined; - rest = itemTok.rest; - const amountTok = nextToken(rest); - const amount = amountTok ? Number(amountTok.token) : 1; - rest = amountTok?.rest ?? rest; - const dataTok = nextToken(rest); - const data = dataTok ? Number(dataTok.token) : 0; - const itemId = normalizeItemTypeId(itemTok.token); - if (!itemId) return undefined; - const n = Number.isFinite(amount) && amount > 0 ? Math.floor(amount) : 1; - return { - kind: "give", - itemId, - amount: Math.min(n, 255), - data: Number.isFinite(data) ? Math.floor(data) : 0, - }; - } - - const clear = /^clear\s+/i.exec(cmd); - if (clear) { - let rest = cmd.slice(clear[0].length); - const targetTok = nextToken(rest); - if (!targetTok) return undefined; - rest = targetTok.rest; - const itemTok = nextToken(rest); - if (!itemTok) return undefined; - rest = itemTok.rest; - // clear:data 在前,maxCount 在后(与 give 的 amount/data 顺序不同) - const dataTok = nextToken(rest); - const data = dataTok ? Number(dataTok.token) : -1; - rest = dataTok?.rest ?? rest; - const countTok = nextToken(rest); - const maxCount = countTok ? Number(countTok.token) : -1; - const itemId = normalizeItemTypeId(itemTok.token); - if (!itemId) return undefined; - return { - kind: "clear", - itemId, - data: Number.isFinite(data) ? Math.floor(data) : -1, - maxCount: Number.isFinite(maxCount) ? Math.floor(maxCount) : -1, - }; - } - - return undefined; -} - -/** 从容器移除最多 maxCount 个同 typeId 物品(data 忽略);maxCount<0 表示清光。 */ -export function clearItemsFromContainer( - container: FakeContainer, - itemId: string, - maxCount: number -): number { - const want = normalizeItemTypeId(itemId); - let left = maxCount < 0 ? Number.POSITIVE_INFINITY : Math.max(0, maxCount); - let removed = 0; - for (let i = 0; i < container.size && left > 0; i++) { - const stack = container.getItem(i); - if (!stack || stack.typeId !== want) continue; - if (stack.amount <= left) { - removed += stack.amount; - left -= stack.amount; - container.setItem(i, undefined); - } else { - removed += left; - const next = stack.clone(); - next.amount -= left; - container.setItem(i, next); - left = 0; - } - } - return removed; -} - -export type ParsedAbility = { - target: string; - ability: string; - value: boolean; -}; - -/** - * 解析 `ability `(area/fly 主路径)。 - * 不匹配则返回 undefined。 - */ -export function parseAbility(cmd: string): ParsedAbility | undefined { - const m = /^ability\s+/i.exec(cmd); - if (!m) return undefined; - let rest = cmd.slice(m[0].length); - const targetTok = nextToken(rest); - if (!targetTok) return undefined; - rest = targetTok.rest; - const abilityTok = nextToken(rest); - if (!abilityTok) return undefined; - rest = abilityTok.rest; - const valueTok = nextToken(rest); - if (!valueTok) return undefined; - const raw = valueTok.token.trim().toLowerCase(); - let value: boolean | undefined; - if (raw === "true" || raw === "1") value = true; - else if (raw === "false" || raw === "0") value = false; - if (value === undefined) return undefined; - const ability = abilityTok.token.trim().toLowerCase(); - if (!ability) return undefined; - return { target: targetTok.token, ability, value }; -} - -export type ThinCommandOpts = { - onGamemode?: (mode: string) => void; - /** 玩家名:用于判断 give/clear/ability 目标是否为本实体 */ - selfName?: string; - /** 本实体物品栏;缺省则 give/clear 只记录 */ - inventory?: FakeContainer; - /** ability 副作用(area/fly:mayfly 等) */ - onAbility?: (ability: string, value: boolean) => void; - /** 从命令串提取目标 token,供 isSelfCommandTarget */ - resolveTarget?: (cmd: string) => string | undefined; -}; - -function extractTarget(cmd: string): string | undefined { - const m = /^(?:give|clear|ability)\s+/i.exec(cmd); - if (!m) return undefined; - const tok = nextToken(cmd.slice(m[0].length)); - return tok?.token; -} - -/** - * 记录命令;可选 gamemode / give / clear / ability 副作用。 - */ -export function runThinCommand( - commandLog: string[], - commandString: string, - opts?: ThinCommandOpts -): FakeCommandResult { - const cmd = String(commandString ?? "") - .replace(/^\//, "") - .trim(); - commandLog.push(cmd); - - const gm = /^gamemode\s+(\S+)/i.exec(cmd); - if (gm && opts?.onGamemode) { - const mode = normalizeGameMode(gm[1]); - if (mode) opts.onGamemode(mode); - } - - if (opts?.onAbility) { - const ability = parseAbility(cmd); - if (ability && isSelfCommandTarget(ability.target, opts.selfName)) { - opts.onAbility(ability.ability, ability.value); - } - } - - const inv = opts?.inventory; - if (inv) { - const parsed = parseGiveOrClear(cmd); - if (parsed) { - const target = (opts.resolveTarget ?? extractTarget)(cmd); - if (target && isSelfCommandTarget(target, opts.selfName)) { - if (parsed.kind === "give") { - try { - inv.addItem(new ItemStack(parsed.itemId, parsed.amount)); - } catch { - /* 非法物品 id 等:仍已记录,不炸模块路径 */ - } - } else { - clearItemsFromContainer(inv, parsed.itemId, parsed.maxCount); - } - } - } - } - - return createCommandResult(1); -} diff --git a/_sandbox_archive/testing/engine/overrides/damage-source.ts b/_sandbox_archive/testing/engine/overrides/damage-source.ts deleted file mode 100644 index 067d41f4..00000000 --- a/_sandbox_archive/testing/engine/overrides/damage-source.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * EntityDamageSource 薄袋:从 applyDamage options 抽出 cause / 伤害来源实体。 - * 对齐 pin:EntityApplyDamageOptions | EntityApplyDamageByProjectileOptions → EntityDamageSource。 - * 不模拟击退 / 无敌帧 / 伤害类型表。 - */ - -/** afterEvents.entityHurt / entityDie 上的 damageSource。 */ -export type FakeEntityDamageSource = { - cause: unknown; - damagingEntity?: unknown; - damagingProjectile?: unknown; -}; - -/** 对齐 EntityApplyDamageOptions(近战 / 环境伤等)。 */ -export type FakeEntityApplyDamageOptions = { - cause: unknown; - damagingEntity?: unknown; -}; - -/** - * 对齐 EntityApplyDamageByProjectileOptions。 - * damagingProjectile 必填;无 cause 时归一化为 "projectile"。 - */ -export type FakeEntityApplyDamageByProjectileOptions = { - damagingProjectile: unknown; - damagingEntity?: unknown; -}; - -export type FakeApplyDamageOptions = - | FakeEntityApplyDamageOptions - | FakeEntityApplyDamageByProjectileOptions - | FakeEntityDamageSource - | { damageSource: unknown }; - -function pickEntityRef(v: unknown): unknown | undefined { - if (v == null) return undefined; - // 保留 Entity 实例引用,便于 === 断言;非对象字面量原样丢弃 - if (typeof v !== "object") return undefined; - return v; -} - -/** 归一化为 afterEvents.entityHurt / entityDie 的 damageSource。 */ -export function toDamageSource(options?: unknown): FakeEntityDamageSource { - if (options == null || typeof options !== "object") { - return { cause: "none" }; - } - const o = options as Record; - // 允许直接传 { damageSource } 袋 - if (o.damageSource != null && typeof o.damageSource === "object") { - return toDamageSource(o.damageSource); - } - - const projectile = "damagingProjectile" in o ? pickEntityRef(o.damagingProjectile) : undefined; - const damager = "damagingEntity" in o ? pickEntityRef(o.damagingEntity) : undefined; - - // EntityApplyDamageByProjectileOptions 无 cause → 推断 projectile - let cause: unknown; - if ("cause" in o) { - cause = o.cause ?? "none"; - } else if (projectile !== undefined) { - cause = "projectile"; - } else { - cause = "none"; - } - - const out: FakeEntityDamageSource = { cause }; - if (damager !== undefined) out.damagingEntity = damager; - if (projectile !== undefined) out.damagingProjectile = projectile; - return out; -} diff --git a/_sandbox_archive/testing/engine/overrides/dimension.ts b/_sandbox_archive/testing/engine/overrides/dimension.ts deleted file mode 100644 index 06058d7d..00000000 --- a/_sandbox_archive/testing/engine/overrides/dimension.ts +++ /dev/null @@ -1,463 +0,0 @@ -/** - * 假 Dimension / Block / BlockPermutation — 对照 Learn + pin `.d.ts` 最小 L2。 - * - * 沙箱策略:不模拟未加载区块;`getBlock` 恒返回方块引用,缺省为空气(非 undefined)。 - * `getBlockFromRay`:薄格点步进(area/fly 关飞落地);无物理 / 无完整碰撞箱。 - */ - -import { guardUnimplemented, UnimplementedMinecraftApiError } from "../unimplemented-error.js"; -import type { FakePlayer } from "./player.js"; -import { - createFakeEntity, - filterEntities, - type FakeEntity, - type FakeEntityQueryOptions, -} from "./entity.js"; -import { runThinCommand, type FakeCommandResult } from "./command.js"; - -export type Vector3Like = { x: number; y: number; z: number }; - -/** 对齐 pin `BlockRaycastOptions`;仅 liquid / passable / maxDistance 有语义。 */ -export type FakeBlockRaycastOptions = { - includeLiquidBlocks?: boolean; - includePassableBlocks?: boolean; - maxDistance?: number; - excludePermutations?: unknown[]; - excludeTags?: string[]; - excludeTypes?: string[]; - includePermutations?: unknown[]; - includeTags?: string[]; - includeTypes?: string[]; -}; - -/** 对齐 pin `BlockRaycastHit`;`face` 为 Direction 字符串(Down/Up/…)。 */ -export type FakeBlockRaycastHit = { - block: FakeBlock; - face: string; - faceLocation: Vector3Like; -}; - -export type FakeBlockType = { - id: string; -}; - -export type FakeBlockPermutation = { - localizationKey: string; - type: FakeBlockType; - getAllStates(): Record; - getState(stateName: string): boolean | number | string | undefined; - withState(name: string, value: boolean | number | string): FakeBlockPermutation; - matches(blockName: string, states?: Record): boolean; - hasTag(_tag: string): boolean; - getTags(): string[]; -}; - -export type FakeBlock = { - dimension: FakeDimension; - x: number; - y: number; - z: number; - location: Vector3Like; - isValid: boolean; - readonly isAir: boolean; - readonly isLiquid: boolean; - readonly isWaterlogged: boolean; - readonly typeId: string; - readonly type: FakeBlockType; - readonly permutation: FakeBlockPermutation; - setPermutation(permutation: FakeBlockPermutation): void; - setType(blockType: FakeBlockType | string): void; -}; - -export type FakeDimension = { - id: string; - localizationKey: string; - heightRange: { min: number; max: number }; - /** 沙箱可观测:runCommand 记录 */ - commandLog: string[]; - getBlock(location: Vector3Like): FakeBlock; - /** - * 薄射线:沿方向格点步进,命中首个「可停」方块。 - * 空气永不命中;液体默认穿透(`includeLiquidBlocks`);无通行块表(`includePassableBlocks` 无额外效果)。 - * BlockFilter 的 include/exclude* 非空 → 硬失败。 - */ - getBlockFromRay( - location: Vector3Like, - direction: Vector3Like, - options?: FakeBlockRaycastOptions - ): FakeBlockRaycastHit | undefined; - setBlockPermutation(location: Vector3Like, permutation: FakeBlockPermutation): void; - setBlockType(location: Vector3Like, blockType: FakeBlockType | string): void; - getEntities(options?: FakeEntityQueryOptions): FakeEntity[]; - getEntitiesAtBlockLocation(location: Vector3Like): FakeEntity[]; - /** 糖:等价 getEntities({ typeId });pin 无此方法,沙箱可调。 */ - getEntitiesOfType(entityType: string): FakeEntity[]; - getPlayers(): FakePlayer[]; - /** options.id:沙箱登记用稳定 id(对齐 objects.create Entity) */ - spawnEntity(identifier: string, location: Vector3Like, options?: { id?: string }): FakeEntity; - /** 掉落物:生成 `minecraft:item` 实体(无物理)。 */ - spawnItem(itemStack: { typeId?: string }, location: Vector3Like): FakeEntity; - /** 沙箱不模拟未加载区块,恒 true。 */ - isChunkLoaded(_location: Vector3Like): boolean; - getWeather(): string; - setWeather(weatherType: string): void; - runCommand(commandString: string): FakeCommandResult; - /** 沙箱内部 */ - _acceptEntity(entity: FakeEntity): void; - _dropEntity(entity: FakeEntity): void; - reset(): void; -}; - -export type FakeDimensionHooks = { - getPlayers: () => FakePlayer[]; - onEntitySpawn?: (entity: FakeEntity) => void; - onEntityDie?: (entity: FakeEntity, options?: unknown) => void; - onEntityHealthChange?: (entity: FakeEntity, oldValue: number, newValue: number) => void; - onEntityHurt?: (entity: FakeEntity, damage: number, options?: unknown) => void; -}; - -/** 规格 §6:默认三维(overworld / nether / the_end)。 */ -export const DEFAULT_DIMENSION_IDS = [ - "minecraft:overworld", - "minecraft:nether", - "minecraft:the_end", -] as const; - -function normalizeTypeId(id: string): string { - const s = String(id ?? "").trim(); - if (!s) return "minecraft:air"; - return s.includes(":") ? s : `minecraft:${s}`; -} - -function floorLoc(location: Vector3Like): { x: number; y: number; z: number } { - return { - x: Math.floor(Number(location.x)), - y: Math.floor(Number(location.y)), - z: Math.floor(Number(location.z)), - }; -} - -function cellKey(loc: { x: number; y: number; z: number }): string { - return `${loc.x},${loc.y},${loc.z}`; -} - -const BLOCK_FILTER_KEYS = [ - "excludePermutations", - "excludeTags", - "excludeTypes", - "includePermutations", - "includeTags", - "includeTypes", -] as const; - -/** 默认最大距离:覆盖 overworld 高度跨度,够 area/fly 向下落地。 */ -const DEFAULT_RAY_MAX_DISTANCE = 384; -const RAY_STEP = 0.25; - -function assertNoBlockFilter(options: FakeBlockRaycastOptions | undefined): void { - if (!options) return; - for (const key of BLOCK_FILTER_KEYS) { - const arr = options[key]; - if (Array.isArray(arr) && arr.length > 0) { - throw new UnimplementedMinecraftApiError(`Dimension.getBlockFromRay.options.${key}`); - } - } -} - -function isLiquidTypeId(typeId: string): boolean { - return typeId === "minecraft:water" || typeId === "minecraft:lava"; -} - -/** - * 射线行进方向命中的面(对面):-Y → Up(fly 落地主路径)。 - */ -function faceFromDirection(dx: number, dy: number, dz: number): string { - const ax = Math.abs(dx); - const ay = Math.abs(dy); - const az = Math.abs(dz); - if (ay >= ax && ay >= az) return dy < 0 ? "Up" : "Down"; - if (ax >= az) return dx > 0 ? "West" : "East"; - return dz > 0 ? "North" : "South"; -} - -function faceLocationFor(face: string): Vector3Like { - switch (face) { - case "Up": - return { x: 0.5, y: 1, z: 0.5 }; - case "Down": - return { x: 0.5, y: 0, z: 0.5 }; - case "North": - return { x: 0.5, y: 0.5, z: 0 }; - case "South": - return { x: 0.5, y: 0.5, z: 1 }; - case "West": - return { x: 0, y: 0.5, z: 0.5 }; - case "East": - return { x: 1, y: 0.5, z: 0.5 }; - default: - return { x: 0.5, y: 0.5, z: 0.5 }; - } -} - -function rayShouldStop( - block: FakeBlock, - options: FakeBlockRaycastOptions | undefined -): boolean { - if (block.isAir) return false; - if (isLiquidTypeId(block.typeId)) { - return options?.includeLiquidBlocks === true; - } - // 无通行块目录:非空气非液体一律视为实心;includePassableBlocks 无额外效果 - void options?.includePassableBlocks; - return true; -} - -export function createBlockPermutation( - blockName: string, - states: Record = {} -): FakeBlockPermutation { - const typeId = normalizeTypeId(blockName); - const stateCopy = { ...states }; - const perm: FakeBlockPermutation = { - localizationKey: `tile.${typeId.replace(/^minecraft:/, "")}.name`, - type: { id: typeId }, - getAllStates() { - return { ...stateCopy }; - }, - getState(stateName) { - return stateCopy[stateName]; - }, - withState(name, value) { - return createBlockPermutation(typeId, { ...stateCopy, [name]: value }); - }, - matches(blockName, states) { - if (normalizeTypeId(blockName) !== typeId) return false; - if (!states) return true; - for (const [k, v] of Object.entries(states)) { - if (stateCopy[k] !== v) return false; - } - return true; - }, - hasTag(_tag) { - return false; - }, - getTags() { - return []; - }, - }; - return guardUnimplemented(perm, "BlockPermutation") as FakeBlockPermutation; -} - -/** 对齐 `BlockPermutation.resolve`;`new BlockPermutation()` 硬失败。 */ -export const BlockPermutation = Object.assign( - function BlockPermutationCtor(): never { - throw new UnimplementedMinecraftApiError("new BlockPermutation()"); - }, - { - resolve(blockName: string, states?: Record): FakeBlockPermutation { - return createBlockPermutation(blockName, states ?? {}); - }, - } -); - -const AIR = () => createBlockPermutation("minecraft:air"); - -const HEIGHT: Record = { - "minecraft:overworld": { min: -64, max: 320 }, - "minecraft:nether": { min: 0, max: 128 }, - "minecraft:the_end": { min: 0, max: 256 }, -}; - -function resolveDimensionId(id: string): string { - const raw = String(id || "minecraft:overworld").trim(); - const aliases: Record = { - overworld: "minecraft:overworld", - nether: "minecraft:nether", - the_end: "minecraft:the_end", - end: "minecraft:the_end", - }; - if (aliases[raw]) return aliases[raw]; - return raw.includes(":") ? raw : `minecraft:${raw}`; -} - -export { resolveDimensionId }; - -export function createFakeDimension(id: string, hooks: FakeDimensionHooks): FakeDimension { - const dimId = resolveDimensionId(id); - const cells = new Map(); - const entities: FakeEntity[] = []; - const commandLog: string[] = []; - /** Clear / Rain / Thunder — 无物理,仅状态袋。 */ - let weather = "Clear"; - - let dim!: FakeDimension; - const api: FakeDimension = { - id: dimId, - localizationKey: `dimension.${dimId.replace(/^minecraft:/, "")}`, - heightRange: HEIGHT[dimId] ?? { min: -64, max: 320 }, - commandLog, - getBlock(location) { - const loc = floorLoc(location); - const key = cellKey(loc); - const readPerm = () => cells.get(key) ?? AIR(); - const block: FakeBlock = { - dimension: dim, - x: loc.x, - y: loc.y, - z: loc.z, - location: { x: loc.x, y: loc.y, z: loc.z }, - isValid: true, - get isAir() { - return readPerm().type.id === "minecraft:air"; - }, - get isLiquid() { - const tid = readPerm().type.id; - return tid === "minecraft:water" || tid === "minecraft:lava"; - }, - isWaterlogged: false, - get typeId() { - return readPerm().type.id; - }, - get type() { - return readPerm().type; - }, - get permutation() { - return readPerm(); - }, - setPermutation(permutation) { - const p = permutation ?? AIR(); - if (p.type.id === "minecraft:air" && Object.keys(p.getAllStates()).length === 0) { - cells.delete(key); - } else { - cells.set(key, p); - } - }, - setType(blockType) { - const tid = typeof blockType === "string" ? blockType : blockType.id; - block.setPermutation(createBlockPermutation(tid)); - }, - }; - return guardUnimplemented(block, "Block") as FakeBlock; - }, - getBlockFromRay(location, direction, options) { - assertNoBlockFilter(options); - const dx = Number(direction?.x); - const dy = Number(direction?.y); - const dz = Number(direction?.z); - const len = Math.hypot(dx, dy, dz); - if (!(len > 0)) { - throw new Error("Direction vector cannot be zero"); - } - const ux = dx / len; - const uy = dy / len; - const uz = dz / len; - const maxDistance = - typeof options?.maxDistance === "number" && Number.isFinite(options.maxDistance) - ? Math.max(0, options.maxDistance) - : DEFAULT_RAY_MAX_DISTANCE; - const ox = Number(location?.x); - const oy = Number(location?.y); - const oz = Number(location?.z); - let lastKey: string | undefined; - for (let t = 0; t <= maxDistance + 1e-9; t += RAY_STEP) { - const sample = { x: ox + ux * t, y: oy + uy * t, z: oz + uz * t }; - const block = dim.getBlock(sample); - const key = cellKey({ x: block.x, y: block.y, z: block.z }); - if (key === lastKey) continue; - lastKey = key; - if (!rayShouldStop(block, options)) continue; - const face = faceFromDirection(ux, uy, uz); - return { - block, - face, - faceLocation: faceLocationFor(face), - }; - } - return undefined; - }, - setBlockPermutation(location, permutation) { - dim.getBlock(location).setPermutation(permutation); - }, - setBlockType(location, blockType) { - dim.getBlock(location).setType(blockType); - }, - getEntities(options) { - return filterEntities(entities, options); - }, - getEntitiesAtBlockLocation(location) { - const loc = floorLoc(location); - return entities.filter((e) => { - if (!e.isValid) return false; - const p = e.location; - return Math.floor(p.x) === loc.x && Math.floor(p.y) === loc.y && Math.floor(p.z) === loc.z; - }); - }, - getEntitiesOfType(entityType) { - return filterEntities(entities, { typeId: String(entityType ?? "") }); - }, - getPlayers() { - return hooks.getPlayers(); - }, - spawnEntity(identifier, location, options) { - const entity = createFakeEntity({ - typeId: identifier, - location, - dimension: dim, - ...(options?.id ? { id: options.id } : {}), - onDie: (e, options) => hooks.onEntityDie?.(e, options), - onRemove: (e) => { - const i = entities.indexOf(e); - if (i >= 0) entities.splice(i, 1); - }, - onHealthChange: (e, oldValue, newValue) => hooks.onEntityHealthChange?.(e, oldValue, newValue), - onHurt: (e, damage, options) => hooks.onEntityHurt?.(e, damage, options), - }); - entities.push(entity); - hooks.onEntitySpawn?.(entity); - return entity; - }, - spawnItem(itemStack, location) { - const typeId = - itemStack && typeof itemStack.typeId === "string" ? itemStack.typeId : "minecraft:air"; - const entity = dim.spawnEntity("minecraft:item", location); - entity.nameTag = typeId; - return entity; - }, - isChunkLoaded(_location) { - return true; - }, - getWeather() { - return weather; - }, - setWeather(weatherType) { - weather = String(weatherType ?? "Clear"); - }, - runCommand(commandString) { - return runThinCommand(commandLog, commandString); - }, - _acceptEntity(entity) { - if (!entities.includes(entity)) entities.push(entity); - }, - _dropEntity(entity) { - const i = entities.indexOf(entity); - if (i >= 0) entities.splice(i, 1); - }, - reset() { - cells.clear(); - weather = "Clear"; - commandLog.length = 0; - for (const e of [...entities]) { - if (e.isValid) e.remove(); - } - entities.length = 0; - }, - }; - - dim = guardUnimplemented(api, "Dimension") as FakeDimension; - return dim; -} - -/** `new Dimension()` 硬失败;实例仅经 world.getDimension。 */ -export const Dimension = function DimensionCtor(): never { - throw new UnimplementedMinecraftApiError("new Dimension()"); -}; diff --git a/_sandbox_archive/testing/engine/overrides/dynamic-property.ts b/_sandbox_archive/testing/engine/overrides/dynamic-property.ts deleted file mode 100644 index 2fc0e2e4..00000000 --- a/_sandbox_archive/testing/engine/overrides/dynamic-property.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * 动态属性薄 Map 袋 — Entity / Player / World 共用。 - * 支持类型对齐 pin .d.ts:boolean | number | string | Vector3;undefined/null 删除键。 - * 不模拟容量上限 / 序列化字节精确值。 - */ - -export type FakeDynamicPropertyValue = boolean | number | string | { x: number; y: number; z: number }; - -export type FakeDynamicPropertyBagMethods = { - clearDynamicProperties(): void; - getDynamicProperty(identifier: string): FakeDynamicPropertyValue | undefined; - getDynamicPropertyIds(): string[]; - getDynamicPropertyTotalByteCount(): number; - setDynamicProperty(identifier: string, value?: FakeDynamicPropertyValue | null): void; - setDynamicProperties( - values: Record - ): void; -}; - -function estimateByteCount(key: string, value: unknown): number { - let n = key.length; - if (typeof value === "string") n += value.length; - else if (typeof value === "number" || typeof value === "boolean") n += 8; - else if (value && typeof value === "object") n += 24; // Vector3 粗估 - return n; -} - -/** - * @param isValid 缺省视为始终有效(World);Entity/Player 传入 isValid 门。 - */ -export function createDynamicPropertyBagMethods( - isValid?: () => boolean -): FakeDynamicPropertyBagMethods { - const bag = new Map(); - - function assertValid(): void { - if (isValid && !isValid()) throw new Error("InvalidEntityError"); - } - - const api: FakeDynamicPropertyBagMethods = { - clearDynamicProperties() { - assertValid(); - bag.clear(); - }, - getDynamicProperty(identifier) { - assertValid(); - return bag.get(String(identifier)); - }, - getDynamicPropertyIds() { - assertValid(); - return [...bag.keys()]; - }, - getDynamicPropertyTotalByteCount() { - assertValid(); - let n = 0; - for (const [k, v] of bag) n += estimateByteCount(k, v); - return n; - }, - setDynamicProperty(identifier, value) { - assertValid(); - const key = String(identifier); - if (value === undefined || value === null) bag.delete(key); - else bag.set(key, value as FakeDynamicPropertyValue); - }, - setDynamicProperties(values) { - assertValid(); - for (const [k, v] of Object.entries(values ?? {})) { - api.setDynamicProperty(k, v); - } - }, - }; - return api; -} diff --git a/_sandbox_archive/testing/engine/overrides/effect.ts b/_sandbox_archive/testing/engine/overrides/effect.ts deleted file mode 100644 index e166fb96..00000000 --- a/_sandbox_archive/testing/engine/overrides/effect.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * 假 Effect — 状态袋;不模拟粒子 / 周期伤害 / 属性修饰。 - */ - -import { guardUnimplemented, UnimplementedMinecraftApiError } from "../unimplemented-error.js"; - -export type FakeEffect = { - readonly amplifier: number; - readonly displayName: string; - readonly duration: number; - readonly isValid: boolean; - readonly typeId: string; -}; - -export type FakeEntityEffectOptions = { - amplifier?: number; - showParticles?: boolean; -}; - -/** 规范化 effect 类型 id(字符串或带 getName() 的 EffectType)。 */ -export function normalizeEffectTypeId(effectType: unknown): string { - if (effectType == null) return "minecraft:unknown"; - if (typeof effectType === "string") { - const s = effectType.trim(); - if (!s) return "minecraft:unknown"; - return s.includes(":") ? s : `minecraft:${s}`; - } - if (typeof effectType === "object" && typeof (effectType as { getName?: () => string }).getName === "function") { - return normalizeEffectTypeId((effectType as { getName: () => string }).getName()); - } - return normalizeEffectTypeId(String(effectType)); -} - -export function createFakeEffect(opts: { - typeId: string; - duration: number; - amplifier?: number; - displayName?: string; -}): FakeEffect { - const typeId = normalizeEffectTypeId(opts.typeId); - const amplifier = Math.max(0, Math.floor(Number(opts.amplifier) || 0)); - const duration = Math.max(0, Math.floor(Number(opts.duration) || 0)); - const api: FakeEffect = { - typeId, - amplifier, - duration, - displayName: opts.displayName ?? typeId, - isValid: true, - }; - return guardUnimplemented(api, "Effect") as FakeEffect; -} - -/** `new Effect()` 硬失败。 */ -export const Effect = function EffectCtor(): never { - throw new UnimplementedMinecraftApiError("new Effect()"); -}; - -/** - * 挂到 Entity / Player 上的 effect 薄 API。 - * 同 typeId 再次 addEffect 覆盖;不推进 tick、不发 effectAdd 事件。 - */ -export function createEffectBagMethods(isEntityValid: () => boolean) { - /** typeId → Effect */ - const bag = new Map(); - - return { - addEffect( - effectType: unknown, - duration: number, - options?: FakeEntityEffectOptions - ): FakeEffect | undefined { - if (!isEntityValid()) throw new Error("InvalidEntityError"); - const typeId = normalizeEffectTypeId(effectType); - const ticks = Math.floor(Number(duration)); - if (!Number.isFinite(ticks) || ticks <= 0) return undefined; - const effect = createFakeEffect({ - typeId, - duration: ticks, - ...(options?.amplifier !== undefined ? { amplifier: options.amplifier } : {}), - }); - bag.set(typeId, effect); - return effect; - }, - getEffect(effectType: unknown): FakeEffect | undefined { - if (!isEntityValid()) return undefined; - return bag.get(normalizeEffectTypeId(effectType)); - }, - getEffects(): FakeEffect[] { - if (!isEntityValid()) return []; - return [...bag.values()]; - }, - removeEffect(effectType: unknown): boolean { - if (!isEntityValid()) throw new Error("InvalidEntityError"); - return bag.delete(normalizeEffectTypeId(effectType)); - }, - }; -} diff --git a/_sandbox_archive/testing/engine/overrides/entity.ts b/_sandbox_archive/testing/engine/overrides/entity.ts deleted file mode 100644 index cc829780..00000000 --- a/_sandbox_archive/testing/engine/overrides/entity.ts +++ /dev/null @@ -1,343 +0,0 @@ -/** - * 假 Entity — 对照 Learn + pin `.d.ts` 最小 L2。 - * `new Entity()` 硬失败;实例经 Dimension.spawnEntity。 - */ - -import { guardUnimplemented, UnimplementedMinecraftApiError } from "../unimplemented-error.js"; -import type { FakeDimension, Vector3Like } from "./dimension.js"; -import { - createPlayerScoreboardIdentity, - type FakeScoreboardIdentity, -} from "./scoreboard.js"; -import { runThinCommand, type FakeCommandResult } from "./command.js"; -import { - createEntityInventoryComponent, - inventorySizeForEntityType, - isInventoryComponentId, - type FakeEntityInventoryComponent, -} from "./inventory.js"; -import { - createEntityHealthComponent, - defaultMaxHealth, - isHealthComponentId, - type FakeEntityHealthComponent, -} from "./health.js"; -import { - createEffectBagMethods, - type FakeEffect, - type FakeEntityEffectOptions, -} from "./effect.js"; -import { - createDynamicPropertyBagMethods, - type FakeDynamicPropertyValue, -} from "./dynamic-property.js"; -import { - toDamageSource, - type FakeApplyDamageOptions, - type FakeEntityDamageSource, -} from "./damage-source.js"; - -export type FakeEntityQueryOptions = { - type?: string; - typeId?: string; - location?: Vector3Like; - maxDistance?: number; - closest?: number; - tags?: string[]; - excludeTypes?: string[]; - excludeTags?: string[]; -}; - -export type FakeEntityComponent = FakeEntityInventoryComponent | FakeEntityHealthComponent; - -export type FakeEntity = { - id: string; - typeId: string; - location: Vector3Like; - dimension: FakeDimension; - nameTag: string; - isValid: boolean; - isOnGround: boolean; - isSneaking: boolean; - /** d.ts 可写:名牌深度测试 / 渲染距离(薄 L2) */ - nameplateDepthTested: boolean; - nameplateRenderDistance: number; - scoreboardIdentity: FakeScoreboardIdentity; - /** 沙箱可观测:runCommand 记录 */ - commandLog: string[]; - remove(): void; - /** options 透传至 onDie(对齐致死 applyDamage 的 damageSource)。 */ - kill(options?: FakeApplyDamageOptions | FakeEntityDamageSource | unknown): boolean; - /** - * 扣血;<=0 返回 false;血量归零则 kill。不模拟护甲/击退。 - * options 对齐 EntityApplyDamageOptions | EntityApplyDamageByProjectileOptions; - * projectile 路径(含 damagingProjectile)归一化后 cause="projectile",实体引用可 === 断言。 - */ - applyDamage(amount: number, options?: FakeApplyDamageOptions | unknown): boolean; - /** 效果状态袋;不模拟粒子 / 周期伤害。 */ - addEffect( - effectType: string | { getName(): string }, - duration: number, - options?: FakeEntityEffectOptions - ): FakeEffect | undefined; - getEffect(effectType: string | { getName(): string }): FakeEffect | undefined; - getEffects(): FakeEffect[]; - removeEffect(effectType: string | { getName(): string }): boolean; - teleport(location: Vector3Like, teleportOptions?: { dimension?: FakeDimension }): void; - getComponent(componentId: string): FakeEntityComponent | undefined; - hasComponent(componentId: string): boolean; - getComponents(): FakeEntityComponent[]; - getTags(): string[]; - addTag(tag: string): boolean; - removeTag(tag: string): boolean; - hasTag(tag: string): boolean; - getRotation(): { x: number; y: number }; - getHeadLocation(): Vector3Like; - getVelocity(): Vector3Like; - runCommand(commandString: string): FakeCommandResult; - /** 动态属性薄 Map 袋;支持 boolean | number | string | Vector3 */ - clearDynamicProperties(): void; - getDynamicProperty(identifier: string): FakeDynamicPropertyValue | undefined; - getDynamicPropertyIds(): string[]; - getDynamicPropertyTotalByteCount(): number; - setDynamicProperty(identifier: string, value?: FakeDynamicPropertyValue | null): void; - setDynamicProperties( - values: Record - ): void; -}; - -function normalizeEntityTypeId(id: string): string { - let s = String(id ?? "").trim(); - // 忽略事件后缀 horse - const angle = s.indexOf("<"); - if (angle >= 0) s = s.slice(0, angle); - if (!s) return "minecraft:unknown"; - return s.includes(":") ? s : `minecraft:${s}`; -} - -let nextEntityId = 1; - -export type CreateFakeEntityOpts = { - typeId: string; - location: Vector3Like; - dimension: FakeDimension; - id?: string; - nameTag?: string; - /** kill() 时先于 remove 调用(对齐 entityDie);options 来自致死 applyDamage。 */ - onDie?: (entity: FakeEntity, options?: unknown) => void; - onRemove?: (entity: FakeEntity) => void; - /** 生命值变化(entityHealthChanged)。 */ - onHealthChange?: (entity: FakeEntity, oldValue: number, newValue: number) => void; - /** 受伤(entityHurt);不模拟物理。 */ - onHurt?: (entity: FakeEntity, damage: number, options?: unknown) => void; -}; - -export function createFakeEntity(opts: CreateFakeEntityOpts): FakeEntity { - const typeId = normalizeEntityTypeId(opts.typeId); - const tags = new Set(); - const commandLog: string[] = []; - const invSize = inventorySizeForEntityType(typeId); - const inventory = invSize !== undefined ? createEntityInventoryComponent(invSize) : undefined; - let entity!: FakeEntity; - const health = createEntityHealthComponent({ - max: defaultMaxHealth(typeId), - onChange(oldValue, newValue) { - opts.onHealthChange?.(entity, oldValue, newValue); - }, - }); - let loc = { - x: Number(opts.location.x), - y: Number(opts.location.y), - z: Number(opts.location.z), - }; - let dim = opts.dimension; - let valid = true; - const effects = createEffectBagMethods(() => valid); - const dyn = createDynamicPropertyBagMethods(() => valid); - - entity = { - id: opts.id ?? `entity-${nextEntityId++}`, - typeId, - get location() { - return { ...loc }; - }, - get dimension() { - return dim; - }, - nameTag: opts.nameTag ?? "", - get isValid() { - return valid; - }, - isOnGround: true, - isSneaking: false, - nameplateDepthTested: true, - nameplateRenderDistance: 64, - scoreboardIdentity: undefined as unknown as FakeScoreboardIdentity, - commandLog, - remove() { - if (!valid) return; - valid = false; - opts.onRemove?.(entity); - }, - kill(options?: unknown) { - if (!valid) return false; - opts.onDie?.(entity, options); - entity.remove(); - return true; - }, - applyDamage(amount, options) { - if (!valid) throw new Error("InvalidEntityError"); - const n = Number(amount); - if (!Number.isFinite(n) || n <= 0) return false; - const before = health.currentValue; - health.setCurrentValue(before - n); - const dealt = before - health.currentValue; - if (dealt <= 0) return false; - // 归一化为 EntityDamageSource(projectile 无 cause → "projectile";保留 Entity 引用) - const damageSource = options == null ? undefined : toDamageSource(options); - opts.onHurt?.(entity, dealt, damageSource); - if (health.currentValue <= 0) entity.kill(damageSource); - return true; - }, - addEffect: effects.addEffect, - getEffect: effects.getEffect, - getEffects: effects.getEffects, - removeEffect: effects.removeEffect, - teleport(location, teleportOptions) { - if (!valid) throw new Error("InvalidEntityError"); - loc = { - x: Number(location.x), - y: Number(location.y), - z: Number(location.z), - }; - const nextDim = teleportOptions?.dimension; - if (nextDim && nextDim !== dim) { - dim._dropEntity?.(entity); - nextDim._acceptEntity?.(entity); - dim = nextDim; - } - }, - getComponent(componentId) { - if (!valid) return undefined; - if (isHealthComponentId(componentId)) return health; - if (inventory && isInventoryComponentId(componentId)) return inventory; - return undefined; - }, - hasComponent(componentId) { - return entity.getComponent(componentId) !== undefined; - }, - getComponents() { - const list: FakeEntityComponent[] = [health]; - if (inventory) list.push(inventory); - return list; - }, - getTags() { - return [...tags]; - }, - addTag(tag) { - if (tags.has(tag)) return false; - tags.add(tag); - return true; - }, - removeTag(tag) { - return tags.delete(tag); - }, - hasTag(tag) { - return tags.has(tag); - }, - getRotation() { - return { x: 0, y: 0 }; - }, - getHeadLocation() { - return { x: loc.x, y: loc.y + 1.62, z: loc.z }; - }, - getVelocity() { - return { x: 0, y: 0, z: 0 }; - }, - runCommand(commandString) { - if (!valid) throw new Error("InvalidEntityError"); - return runThinCommand( - commandLog, - commandString, - inventory ? { inventory: inventory.container } : undefined - ); - }, - clearDynamicProperties: dyn.clearDynamicProperties, - getDynamicProperty: dyn.getDynamicProperty, - getDynamicPropertyIds: dyn.getDynamicPropertyIds, - getDynamicPropertyTotalByteCount: dyn.getDynamicPropertyTotalByteCount, - setDynamicProperty: dyn.setDynamicProperty, - setDynamicProperties: dyn.setDynamicProperties, - }; - - entity.scoreboardIdentity = createPlayerScoreboardIdentity( - { name: entity.nameTag || entity.id }, - () => (valid ? entity : undefined), - "Entity" - ); - - // 回调 / 事件载荷与 spawnEntity 返回值同为 Proxy,便于 === 断言(含 damagingProjectile) - entity = guardUnimplemented(entity, "Entity") as FakeEntity; - return entity; -} - -/** `new Entity()` 硬失败。 */ -export const Entity = function EntityCtor(): never { - throw new UnimplementedMinecraftApiError("new Entity()"); -}; - -export function resetEntityIdCounter(): void { - nextEntityId = 1; -} - -export function filterEntities( - list: FakeEntity[], - options?: FakeEntityQueryOptions -): FakeEntity[] { - let out = list.filter((e) => e.isValid); - if (!options) return out; - - const typeFilter = options.typeId ?? options.type; - if (typeFilter) { - const want = normalizeEntityTypeId(typeFilter); - out = out.filter((e) => e.typeId === want); - } - if (options.excludeTypes?.length) { - const excl = new Set(options.excludeTypes.map(normalizeEntityTypeId)); - out = out.filter((e) => !excl.has(e.typeId)); - } - if (options.tags?.length) { - out = out.filter((e) => options.tags!.every((t) => e.hasTag(t))); - } - if (options.excludeTags?.length) { - out = out.filter((e) => !options.excludeTags!.some((t) => e.hasTag(t))); - } - if (options.location && options.maxDistance !== undefined) { - const origin = options.location; - const maxD = options.maxDistance; - out = out.filter((e) => { - const dx = e.location.x - origin.x; - const dy = e.location.y - origin.y; - const dz = e.location.z - origin.z; - return dx * dx + dy * dy + dz * dz <= maxD * maxD; - }); - } - if (options.location && options.closest !== undefined) { - const origin = options.location; - out = [...out].sort((a, b) => { - const da = - (a.location.x - origin.x) ** 2 + - (a.location.y - origin.y) ** 2 + - (a.location.z - origin.z) ** 2; - const db = - (b.location.x - origin.x) ** 2 + - (b.location.y - origin.y) ** 2 + - (b.location.z - origin.z) ** 2; - return da - db; - }); - out = out.slice(0, Math.max(0, options.closest)); - } - return out; -} - -export { normalizeEntityTypeId }; diff --git a/_sandbox_archive/testing/engine/overrides/events.ts b/_sandbox_archive/testing/engine/overrides/events.ts deleted file mode 100644 index 681dbcc5..00000000 --- a/_sandbox_archive/testing/engine/overrides/events.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * 事件信号:对齐 @minecraft/server 的 subscribe / unsubscribe 形状。 - */ - -export type EventHandler = (event: T) => void; - -/** emit 同步调用所有订阅后的摘要(测试 / playground 可观测)。 */ -export type EventEmitResult = { - listeners: number; - errors: { message: string; stack?: string }[]; -}; - -export interface EventSignal { - subscribe(handler: EventHandler, options?: unknown): (event: T) => void; - unsubscribe(handler: EventHandler): void; - /** 测试侧触发(非 MC 公开 API,仅沙箱):同步调用已订阅回调。 */ - emit(event: T): EventEmitResult; - clear(): void; - size(): number; -} - -function toEmitError(e: unknown): { message: string; stack?: string } { - if (e instanceof Error) { - const row: { message: string; stack?: string } = { message: e.message }; - if (e.stack) row.stack = e.stack; - return row; - } - return { message: String(e) }; -} - -export function createEventSignal(): EventSignal { - const handlers: EventHandler[] = []; - return { - subscribe(handler, _options?) { - handlers.push(handler); - return handler; - }, - unsubscribe(handler) { - const i = handlers.indexOf(handler); - if (i >= 0) handlers.splice(i, 1); - }, - emit(event) { - const snapshot = [...handlers]; - const errors: { message: string; stack?: string }[] = []; - for (const h of snapshot) { - try { - const ret = h(event) as unknown; - // async 回调拒绝无法进同步 errors,至少打日志避免「看起来没跑到」 - if (ret != null && typeof (ret as { then?: unknown }).then === "function") { - void Promise.resolve(ret).catch((e) => { - const err = toEmitError(e); - console.error(`[sfmc-testing] event listener rejected: ${err.message}`); - }); - } - } catch (e) { - const err = toEmitError(e); - errors.push(err); - console.error(`[sfmc-testing] event listener threw: ${err.message}`); - } - } - return { listeners: snapshot.length, errors }; - }, - clear() { - handlers.length = 0; - }, - size() { - return handlers.length; - }, - }; -} - -/** 1:1 事件 hub:访问任意信号名时惰性 createEventSignal。 */ -export function createEventHub( - seed?: Record> -): Record> & { _clearAll(): void; _signals(): Map> } { - const map = new Map>(); - if (seed) { - for (const [k, v] of Object.entries(seed)) map.set(k, v); - } - const hub = new Proxy({} as Record>, { - get(_t, prop) { - if (prop === "_clearAll") { - return () => { - for (const s of map.values()) s.clear(); - map.clear(); - }; - } - if (prop === "_signals") return () => map; - if (typeof prop === "symbol") return undefined; - if (prop === "then") return undefined; - if (!map.has(prop)) map.set(prop, createEventSignal()); - return map.get(prop); - }, - has(_t, prop) { - return typeof prop === "string" && prop !== "then"; - }, - ownKeys() { - return [...map.keys()]; - }, - getOwnPropertyDescriptor(_t, prop) { - if (typeof prop !== "string") return undefined; - return { configurable: true, enumerable: true, value: hub[prop] }; - }, - }) as Record> & { - _clearAll(): void; - _signals(): Map>; - }; - return hub; -} diff --git a/_sandbox_archive/testing/engine/overrides/exports.json b/_sandbox_archive/testing/engine/overrides/exports.json deleted file mode 100644 index 71b0c43c..00000000 --- a/_sandbox_archive/testing/engine/overrides/exports.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "server": [ - "world", - "system", - "Player", - "PlayerPermissionLevel", - "GameMode", - "ItemStack", - "Entity", - "BlockComponentTypes", - "BlockPermutation", - "Dimension", - "EntityInventoryComponent", - "EntityHealthComponent", - "Effect", - "EntityInitializationCause", - "default" - ], - "serverUi": [ - "ActionFormData", - "MessageFormData", - "ModalFormData", - "CustomForm", - "MessageBox", - "FormCancelationReason", - "DataDrivenScreenClosedReason", - "ObservableBoolean", - "ObservableNumber", - "ObservableString", - "ObservableUIRawMessage", - "uiManager", - "default" - ] -} diff --git a/_sandbox_archive/testing/engine/overrides/health.ts b/_sandbox_archive/testing/engine/overrides/health.ts deleted file mode 100644 index 08cdaa71..00000000 --- a/_sandbox_archive/testing/engine/overrides/health.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * 假 EntityHealthComponent — 生命值袋,不模拟护甲/抗性/击退等物理。 - */ - -import { guardUnimplemented, UnimplementedMinecraftApiError } from "../unimplemented-error.js"; - -export const HEALTH_COMPONENT_ID = "minecraft:health" as const; - -export type FakeEntityHealthComponent = { - typeId: string; - readonly currentValue: number; - readonly defaultValue: number; - readonly effectiveMax: number; - readonly effectiveMin: number; - resetToDefaultValue(): void; - resetToMaxValue(): void; - resetToMinValue(): void; - setCurrentValue(value: number): boolean; -}; - -export type CreateHealthOpts = { - max?: number; - current?: number; - defaultValue?: number; - min?: number; - /** 值变化时回调(applyDamage / setCurrentValue) */ - onChange?: (oldValue: number, newValue: number) => void; -}; - -export function createEntityHealthComponent(opts: CreateHealthOpts = {}): FakeEntityHealthComponent { - const effectiveMax = opts.max ?? 20; - const effectiveMin = opts.min ?? 0; - const defaultValue = opts.defaultValue ?? effectiveMax; - let current = opts.current ?? defaultValue; - current = Math.min(effectiveMax, Math.max(effectiveMin, current)); - - const set = (next: number): boolean => { - const clamped = Math.min(effectiveMax, Math.max(effectiveMin, next)); - if (clamped === current) return false; - const old = current; - current = clamped; - opts.onChange?.(old, current); - return true; - }; - - const api: FakeEntityHealthComponent = { - typeId: HEALTH_COMPONENT_ID, - get currentValue() { - return current; - }, - defaultValue, - effectiveMax, - effectiveMin, - resetToDefaultValue() { - set(defaultValue); - }, - resetToMaxValue() { - set(effectiveMax); - }, - resetToMinValue() { - set(effectiveMin); - }, - setCurrentValue(value) { - const n = Number(value); - if (!Number.isFinite(n)) return false; - set(n); - return true; - }, - }; - - return guardUnimplemented(api, "EntityHealthComponent") as FakeEntityHealthComponent; -} - -/** 对齐 static componentId;`new EntityHealthComponent()` 硬失败。 */ -export const EntityHealthComponent = Object.assign( - function EntityHealthComponentCtor(): never { - throw new UnimplementedMinecraftApiError("new EntityHealthComponent()"); - }, - { - componentId: HEALTH_COMPONENT_ID, - } -); - -export function isHealthComponentId(componentId: string): boolean { - const id = String(componentId ?? ""); - return ( - id === HEALTH_COMPONENT_ID || - id === "health" || - id === EntityHealthComponent.componentId - ); -} - -/** 默认最大生命:玩家 20;其它实体也用 20(薄 L2,不按 typeId 查表)。 */ -export function defaultMaxHealth(_typeId?: string): number { - return 20; -} diff --git a/_sandbox_archive/testing/engine/overrides/inventory.ts b/_sandbox_archive/testing/engine/overrides/inventory.ts deleted file mode 100644 index c6f8e4d3..00000000 --- a/_sandbox_archive/testing/engine/overrides/inventory.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * 假 ItemStack / Container / EntityInventoryComponent — 对照 Learn + pin `.d.ts`。 - */ - -import { guardUnimplemented, UnimplementedMinecraftApiError } from "../unimplemented-error.js"; - -function normalizeItemTypeId(id: string): string { - const s = String(id ?? "").trim(); - if (!s) throw new Error("Invalid item type"); - return s.includes(":") ? s : `minecraft:${s}`; -} - -export type FakeItemType = { id: string }; - -export class ItemStack { - amount: number; - keepOnDeath = false; - nameTag?: string; - lockMode = "none"; - readonly type: FakeItemType; - readonly typeId: string; - readonly maxAmount: number; - readonly localizationKey: string; - - constructor(itemType: string | FakeItemType, amount = 1) { - const id = - typeof itemType === "string" ? normalizeItemTypeId(itemType) : normalizeItemTypeId(itemType.id); - this.typeId = id; - this.type = { id }; - this.maxAmount = 64; - this.localizationKey = `item.${id.replace(/^minecraft:/, "")}.name`; - const n = Number(amount); - if (!Number.isFinite(n) || n < 1 || n > 255) { - throw new Error("ItemStack amount must be between 1 and 255"); - } - this.amount = Math.min(Math.floor(n), this.maxAmount); - // 构造返回 Proxy:缺成员硬失败(与 Player/Entity 一致) - return guardUnimplemented(this, "ItemStack") as ItemStack; - } - - get isStackable(): boolean { - return this.maxAmount > 1; - } - - get weight(): number { - return this.amount; - } - - clone(): ItemStack { - const c = new ItemStack(this.typeId, this.amount); - c.keepOnDeath = this.keepOnDeath; - if (this.nameTag !== undefined) c.nameTag = this.nameTag; - c.lockMode = this.lockMode; - return c; - } - - isStackableWith(otherStack: ItemStack): boolean { - return this.typeId === otherStack.typeId && this.isStackable && otherStack.isStackable; - } - - getComponent(_componentId: string): undefined { - return undefined; - } - - hasComponent(_componentId: string): boolean { - return false; - } - - clearDynamicProperties(): void { - /* noop L2 */ - } - - getDynamicProperty(_id: string): undefined { - return undefined; - } - - getDynamicPropertyIds(): string[] { - return []; - } - - setDynamicProperty(_id: string, _value?: unknown): void { - /* noop L2 */ - } -} - -export type FakeContainer = { - size: number; - isValid: boolean; - readonly emptySlotsCount: number; - readonly weight: number; - getItem(slot: number): ItemStack | undefined; - setItem(slot: number, itemStack?: ItemStack): void; - addItem(itemStack: ItemStack): ItemStack | undefined; - clearAll(): void; - transferItem(fromSlot: number, toContainer: FakeContainer): ItemStack | undefined; - swapItems(slot: number, otherSlot: number, otherContainer: FakeContainer): void; - moveItem(fromSlot: number, toSlot: number, toContainer: FakeContainer): void; -}; - -function assertSlot(size: number, slot: number): void { - if (!Number.isInteger(slot) || slot < 0 || slot >= size) { - throw new Error(`Invalid container slot ${slot}`); - } -} - -export function createFakeContainer(size: number): FakeContainer { - const slots: Array = Array.from({ length: size }, () => undefined); - let valid = true; - - const api: FakeContainer = { - size, - get isValid() { - return valid; - }, - get emptySlotsCount() { - return slots.filter((s) => s === undefined).length; - }, - get weight() { - return slots.reduce((sum, s) => sum + (s?.amount ?? 0), 0); - }, - getItem(slot) { - assertSlot(size, slot); - const item = slots[slot]; - return item ? item.clone() : undefined; - }, - setItem(slot, itemStack) { - assertSlot(size, slot); - slots[slot] = itemStack ? itemStack.clone() : undefined; - }, - addItem(itemStack) { - let remaining = itemStack.clone(); - for (let i = 0; i < size; i++) { - const cur = slots[i]; - if (cur && cur.isStackableWith(remaining)) { - const space = cur.maxAmount - cur.amount; - if (space <= 0) continue; - const move = Math.min(space, remaining.amount); - cur.amount += move; - remaining.amount -= move; - if (remaining.amount <= 0) return undefined; - } - } - for (let i = 0; i < size; i++) { - if (slots[i] === undefined) { - slots[i] = remaining; - return undefined; - } - } - return remaining; - }, - clearAll() { - for (let i = 0; i < size; i++) slots[i] = undefined; - }, - transferItem(fromSlot, toContainer) { - const item = api.getItem(fromSlot); - if (!item) return undefined; - api.setItem(fromSlot, undefined); - const leftover = toContainer.addItem(item); - if (leftover) { - api.setItem(fromSlot, leftover); - return leftover; - } - return undefined; - }, - swapItems(slot, otherSlot, otherContainer) { - const a = api.getItem(slot); - const b = otherContainer.getItem(otherSlot); - api.setItem(slot, b); - otherContainer.setItem(otherSlot, a); - }, - moveItem(fromSlot, toSlot, toContainer) { - const item = api.getItem(fromSlot); - api.setItem(fromSlot, undefined); - toContainer.setItem(toSlot, item); - }, - }; - - return guardUnimplemented(api, "Container") as FakeContainer; -} - -export type FakeEntityInventoryComponent = { - typeId: string; - container: FakeContainer; - inventorySize: number; - containerType: string; - additionalSlotsPerStrength: number; - canBeSiphonedFrom: boolean; - private: boolean; - restrictToOwner: boolean; -}; - -export function createEntityInventoryComponent( - size: number, - containerType = "inventory" -): FakeEntityInventoryComponent { - const container = createFakeContainer(size); - return guardUnimplemented( - { - typeId: "minecraft:inventory", - container, - inventorySize: size, - containerType, - additionalSlotsPerStrength: 0, - canBeSiphonedFrom: true, - private: false, - restrictToOwner: false, - }, - "EntityInventoryComponent" - ) as FakeEntityInventoryComponent; -} - -/** 对齐 static componentId;`new EntityInventoryComponent()` 硬失败。 */ -export const EntityInventoryComponent = Object.assign( - function EntityInventoryComponentCtor(): never { - throw new UnimplementedMinecraftApiError("new EntityInventoryComponent()"); - }, - { - componentId: "minecraft:inventory" as const, - } -); - -export function inventorySizeForEntityType(typeId: string): number | undefined { - let id = String(typeId ?? "").trim(); - const angle = id.indexOf("<"); - if (angle >= 0) id = id.slice(0, angle); - if (!id.includes(":")) id = `minecraft:${id}`; - if (id === "minecraft:player") return 36; - if (id === "minecraft:chest_minecart" || id === "minecraft:chest_boat") return 27; - if (id === "minecraft:hopper_minecart") return 5; - return 27; -} - -export function isInventoryComponentId(componentId: string): boolean { - const id = String(componentId ?? ""); - return ( - id === "minecraft:inventory" || - id === "inventory" || - id === EntityInventoryComponent.componentId - ); -} diff --git a/_sandbox_archive/testing/engine/overrides/player.ts b/_sandbox_archive/testing/engine/overrides/player.ts deleted file mode 100644 index 8bbca645..00000000 --- a/_sandbox_archive/testing/engine/overrides/player.ts +++ /dev/null @@ -1,352 +0,0 @@ -/** - * 假 Player:对齐 Msg.* → sendMessage,并带权限等级、GameMode、runCommand 等常用字段。 - */ - -import { - createPlayerScoreboardIdentity, - type FakeScoreboardIdentity, -} from "./scoreboard.js"; -import type { FakeDimension } from "./dimension.js"; -import { - createEntityInventoryComponent, - isInventoryComponentId, - type FakeEntityInventoryComponent, -} from "./inventory.js"; -import { - createEntityHealthComponent, - defaultMaxHealth, - isHealthComponentId, - type FakeEntityHealthComponent, -} from "./health.js"; -import { - createEffectBagMethods, - type FakeEffect, - type FakeEntityEffectOptions, -} from "./effect.js"; -import { - createDynamicPropertyBagMethods, - type FakeDynamicPropertyValue, -} from "./dynamic-property.js"; -import { toDamageSource } from "./damage-source.js"; -import { guardUnimplemented } from "../unimplemented-error.js"; -import { normalizeGameMode, runThinCommand, type FakeCommandResult } from "./command.js"; -import { createFakeScreenDisplay, type FakeScreenDisplay } from "./screen-display.js"; - -export type FakeDimensionLocation = { - dimension: FakeDimension; - x: number; - y: number; - z: number; -}; - -export type FakePlayerComponent = FakeEntityInventoryComponent | FakeEntityHealthComponent; - -export type FakePlayer = { - id: string; - /** Entity.typeId;玩家固定 minecraft:player */ - typeId: string; - name: string; - /** 头顶名牌;默认等于 name */ - nameTag: string; - log: string[]; - /** 沙箱可观测:runCommand 记录(不含前导 /) */ - commandLog: string[]; - /** 沙箱可观测:playSound 记录 */ - soundLog: string[]; - /** - * 沙箱可观测:ability 命令写入的能力袋(如 mayfly)。 - * 不实现 Player.isFlying(见 l2-skip.json)。 - */ - abilities: Record; - playerPermissionLevel: number; - /** d.ts 可写:命令权限等级(薄 L2,数字存) */ - commandPermissionLevel: number; - /** d.ts 可写:热键栏槽位 */ - selectedSlotIndex: number; - chatMessagePrefix: string; - chatNamePrefix: string; - chatNameSuffix: string; - isSneaking: boolean; - nameplateDepthTested: boolean; - nameplateRenderDistance: number; - location: { x: number; y: number; z: number }; - dimension: FakeDimension; - scoreboardIdentity: FakeScoreboardIdentity; - readonly onScreenDisplay: FakeScreenDisplay; - sendMessage(text: string): void; - isValid: boolean; - teleport(location: { x: number; y: number; z: number }, teleportOptions?: { dimension?: FakeDimension }): void; - getTags(): string[]; - addTag(tag: string): boolean; - removeTag(tag: string): boolean; - hasTag(tag: string): boolean; - getComponent(componentId: string): FakePlayerComponent | undefined; - hasComponent(componentId: string): boolean; - getComponents(): FakePlayerComponent[]; - getRotation(): { x: number; y: number }; - getHeadLocation(): { x: number; y: number; z: number }; - getVelocity(): { x: number; y: number; z: number }; - getGameMode(): string; - setGameMode(gameMode?: string): void; - getSpawnPoint(): FakeDimensionLocation | undefined; - setSpawnPoint(spawnPoint?: FakeDimensionLocation): void; - playSound(soundId: string | { id?: string }, soundOptions?: unknown): { id: string }; - runCommand(commandString: string): FakeCommandResult; - /** - * 扣血;不模拟物理;归零则 isValid=false(不经 Dimension.remove)。 - * options 对齐 EntityApplyDamageOptions | EntityApplyDamageByProjectileOptions。 - */ - applyDamage(amount: number, options?: unknown): boolean; - /** 效果状态袋;不模拟粒子 / 周期伤害。 */ - addEffect( - effectType: string | { getName(): string }, - duration: number, - options?: FakeEntityEffectOptions - ): FakeEffect | undefined; - getEffect(effectType: string | { getName(): string }): FakeEffect | undefined; - getEffects(): FakeEffect[]; - removeEffect(effectType: string | { getName(): string }): boolean; - /** options 透传至 onDie(对齐致死 applyDamage 的 damageSource)。 */ - kill(options?: unknown): boolean; - /** 动态属性薄 Map 袋;支持 boolean | number | string | Vector3 */ - clearDynamicProperties(): void; - getDynamicProperty(identifier: string): FakeDynamicPropertyValue | undefined; - getDynamicPropertyIds(): string[]; - getDynamicPropertyTotalByteCount(): number; - setDynamicProperty(identifier: string, value?: FakeDynamicPropertyValue | null): void; - setDynamicProperties( - values: Record - ): void; -}; - -export interface FakePlayerInit { - id?: string; - name: string; - /** 对应 PlayerPermissionLevel:0 Visitor / 1 Member / 2 Operator / 3 Custom */ - op?: boolean; - permissionLevel?: number; - location?: { x: number; y: number; z: number }; - nameTag?: string; - dimensionId?: string; - /** 初始游戏模式;默认 survival */ - gameMode?: string; - /** setGameMode 时回调(sandbox/world 用于 emit playerGameModeChange) */ - onGameModeChange?: (player: FakePlayer, from: string, to: string) => void; - onHealthChange?: (player: FakePlayer, oldValue: number, newValue: number) => void; - onHurt?: (player: FakePlayer, damage: number, options?: unknown) => void; - onDie?: (player: FakePlayer, options?: unknown) => void; -} - -export function createEnginePlayer(init: FakePlayerInit): FakePlayer { - const log: string[] = []; - const commandLog: string[] = []; - const soundLog: string[] = []; - /** mayfly 默认关;其余 ability 按命令写入 */ - const abilities: Record = { mayfly: false }; - const level = init.permissionLevel ?? (init.op ? 2 /* Operator */ : 1 /* Member */); - const dimId = init.dimensionId ?? "minecraft:overworld"; - const inventory = createEntityInventoryComponent(36); - const tags = new Set(); - let gameMode = normalizeGameMode(init.gameMode) ?? "Survival"; - let spawnPoint: FakeDimensionLocation | undefined; - // dimension 由 sandbox/world.addPlayer 前替换为世界内真维度;此处先放占位 id - const placeholderDim = { - id: dimId.includes(":") ? dimId : `minecraft:${dimId}`, - localizationKey: "", - heightRange: { min: -64, max: 320 }, - getBlock() { - throw new Error("player.dimension 尚未绑定到 FakeWorld;请经 createSandbox().addPlayer 添加"); - }, - setBlockPermutation() { - throw new Error("player.dimension 尚未绑定到 FakeWorld"); - }, - setBlockType() { - throw new Error("player.dimension 尚未绑定到 FakeWorld"); - }, - getEntities: () => [], - getEntitiesAtBlockLocation: () => [], - getEntitiesOfType: () => [], - getPlayers: () => [] as FakePlayer[], - spawnEntity() { - throw new Error("player.dimension 尚未绑定到 FakeWorld"); - }, - spawnItem() { - throw new Error("player.dimension 尚未绑定到 FakeWorld"); - }, - isChunkLoaded: () => true, - getWeather: () => "Clear", - setWeather() { - throw new Error("player.dimension 尚未绑定到 FakeWorld"); - }, - runCommand() { - throw new Error("player.dimension 尚未绑定到 FakeWorld"); - }, - commandLog: [], - _acceptEntity() {}, - _dropEntity() {}, - reset() {}, - } as unknown as FakeDimension; - - let player!: FakePlayer; - const screen = createFakeScreenDisplay(() => player.isValid); - const health = createEntityHealthComponent({ - max: defaultMaxHealth("minecraft:player"), - onChange(oldValue, newValue) { - init.onHealthChange?.(player, oldValue, newValue); - }, - }); - const effects = createEffectBagMethods(() => player.isValid); - const dyn = createDynamicPropertyBagMethods(() => player.isValid); - - player = { - id: init.id ?? `player-${init.name}`, - typeId: "minecraft:player", - name: init.name, - nameTag: init.nameTag ?? init.name, - log, - commandLog, - soundLog, - abilities, - playerPermissionLevel: level, - commandPermissionLevel: level, - selectedSlotIndex: 0, - chatMessagePrefix: "", - chatNamePrefix: "", - chatNameSuffix: "", - isSneaking: false, - nameplateDepthTested: true, - nameplateRenderDistance: 64, - location: init.location ?? { x: 0, y: 64, z: 0 }, - dimension: placeholderDim, - scoreboardIdentity: undefined as unknown as FakeScoreboardIdentity, - onScreenDisplay: screen, - isValid: true, - sendMessage(text: string) { - log.push(String(text ?? "")); - }, - teleport(location, teleportOptions) { - if (!player.isValid) throw new Error("InvalidEntityError"); - player.location = { - x: Number(location.x), - y: Number(location.y), - z: Number(location.z), - }; - const nextDim = teleportOptions?.dimension; - if (nextDim) player.dimension = nextDim; - }, - getTags() { - return [...tags]; - }, - addTag(tag) { - if (tags.has(tag)) return false; - tags.add(tag); - return true; - }, - removeTag(tag) { - return tags.delete(tag); - }, - hasTag(tag) { - return tags.has(tag); - }, - getComponent(componentId) { - if (!player.isValid) return undefined; - if (isHealthComponentId(componentId)) return health; - if (isInventoryComponentId(componentId)) return inventory; - return undefined; - }, - hasComponent(componentId) { - return player.getComponent(componentId) !== undefined; - }, - getComponents() { - return [health, inventory]; - }, - getRotation() { - return { x: 0, y: 0 }; - }, - getHeadLocation() { - const loc = player.location; - return { x: loc.x, y: loc.y + 1.62, z: loc.z }; - }, - getVelocity() { - return { x: 0, y: 0, z: 0 }; - }, - getGameMode() { - return gameMode; - }, - setGameMode(nextRaw) { - if (!player.isValid) throw new Error("InvalidEntityError"); - const next = normalizeGameMode(nextRaw) ?? "Survival"; - const from = gameMode; - if (from === next) return; - gameMode = next; - init.onGameModeChange?.(player, from, next); - }, - getSpawnPoint() { - return spawnPoint ? { ...spawnPoint } : undefined; - }, - setSpawnPoint(point) { - if (!player.isValid) throw new Error("InvalidEntityError"); - if (point == null) { - spawnPoint = undefined; - return; - } - spawnPoint = { - dimension: point.dimension ?? player.dimension, - x: Number(point.x), - y: Number(point.y), - z: Number(point.z), - }; - }, - playSound(soundId) { - if (!player.isValid) throw new Error("InvalidEntityError"); - const id = - typeof soundId === "string" - ? soundId - : String((soundId as { id?: string })?.id ?? soundId ?? ""); - soundLog.push(id); - return { id }; - }, - runCommand(commandString) { - if (!player.isValid) throw new Error("InvalidEntityError"); - return runThinCommand(commandLog, commandString, { - onGamemode: (mode) => player.setGameMode(mode), - onAbility: (ability, value) => { - abilities[ability] = value; - }, - selfName: player.name, - inventory: inventory.container, - }); - }, - applyDamage(amount, options) { - if (!player.isValid) throw new Error("InvalidEntityError"); - const n = Number(amount); - if (!Number.isFinite(n) || n <= 0) return false; - const before = health.currentValue; - health.setCurrentValue(before - n); - const dealt = before - health.currentValue; - if (dealt <= 0) return false; - const damageSource = options == null ? undefined : toDamageSource(options); - init.onHurt?.(player, dealt, damageSource); - if (health.currentValue <= 0) player.kill(damageSource); - return true; - }, - addEffect: effects.addEffect, - getEffect: effects.getEffect, - getEffects: effects.getEffects, - removeEffect: effects.removeEffect, - kill(options?: unknown) { - if (!player.isValid) return false; - init.onDie?.(player, options); - player.isValid = false; - return true; - }, - clearDynamicProperties: dyn.clearDynamicProperties, - getDynamicProperty: dyn.getDynamicProperty, - getDynamicPropertyIds: dyn.getDynamicPropertyIds, - getDynamicPropertyTotalByteCount: dyn.getDynamicPropertyTotalByteCount, - setDynamicProperty: dyn.setDynamicProperty, - setDynamicProperties: dyn.setDynamicProperties, - }; - player.scoreboardIdentity = createPlayerScoreboardIdentity(player, () => player); - return guardUnimplemented(player, "Player") as FakePlayer; -} diff --git a/_sandbox_archive/testing/engine/overrides/scoreboard.ts b/_sandbox_archive/testing/engine/overrides/scoreboard.ts deleted file mode 100644 index 5411634c..00000000 --- a/_sandbox_archive/testing/engine/overrides/scoreboard.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * 假 Scoreboard — 对齐 pin 版 @minecraft/server + Learn: - * getScore 未设分为 undefined;participant 可为 string / Identity / 带 scoreboardIdentity 的实体。 - */ - -export type FakeScoreboardIdentity = { - displayName: string; - id: number; - isValid: boolean; - type: "Player" | "Entity" | "FakePlayer"; - getEntity(): unknown | undefined; -}; - -export type FakeScoreboardScoreInfo = { - participant: FakeScoreboardIdentity; - score: number; -}; - -export type FakeScoreboardObjective = { - id: string; - displayName: string; - isValid: boolean; - addScore(participant: ScoreParticipant, scoreToAdd: number): number; - getParticipants(): FakeScoreboardIdentity[]; - getScore(participant: ScoreParticipant): number | undefined; - getScores(): FakeScoreboardScoreInfo[]; - hasParticipant(participant: ScoreParticipant): boolean; - removeParticipant(participant: ScoreParticipant): boolean; - setScore(participant: ScoreParticipant, score: number): void; -}; - -export type ScoreParticipant = string | FakeScoreboardIdentity | { name?: string; scoreboardIdentity?: FakeScoreboardIdentity }; - -export type FakeScoreboardObjectiveDisplayOptions = { - objective: FakeScoreboardObjective; - sortOrder?: number; -}; - -export type FakeScoreboard = { - addObjective(objectiveId: string, displayName?: string): FakeScoreboardObjective; - getObjective(objectiveId: string): FakeScoreboardObjective | undefined; - getObjectives(): FakeScoreboardObjective[]; - removeObjective(objectiveId: FakeScoreboardObjective | string): boolean; - getParticipants(): FakeScoreboardIdentity[]; - clearObjectiveAtDisplaySlot(displaySlotId: string): FakeScoreboardObjective | undefined; - getObjectiveAtDisplaySlot(displaySlotId: string): FakeScoreboardObjectiveDisplayOptions | undefined; - setObjectiveAtDisplaySlot( - displaySlotId: string, - objectiveDisplaySetting: FakeScoreboardObjectiveDisplayOptions - ): FakeScoreboardObjective | undefined; - /** 沙箱内部:登记玩家身份(addPlayer 时调用)。 */ - _registerIdentity(identity: FakeScoreboardIdentity): void; - reset(): void; -}; - -let nextIdentityId = 1; - -/** 为假玩家/实体创建 ScoreboardIdentity。 */ -export function createPlayerScoreboardIdentity( - player: { name: string }, - getEntity: () => unknown, - type: FakeScoreboardIdentity["type"] = "Player" -): FakeScoreboardIdentity { - return { - displayName: player.name, - id: nextIdentityId++, - isValid: true, - type, - getEntity, - }; -} - -function assertValidObjective(obj: FakeScoreboardObjective): void { - if (!obj.isValid) { - throw new Error(`ScoreboardObjective "${obj.id}" is no longer valid`); - } -} - -function resolveParticipantKey(participant: ScoreParticipant): string { - if (typeof participant === "string") return participant; - if (participant && typeof participant === "object") { - const asIdentity = participant as FakeScoreboardIdentity; - // 先用 in 探测,避免 FakePlayer 全表面代理对缺失字段硬失败 - if ( - "displayName" in asIdentity && - typeof asIdentity.displayName === "string" && - typeof asIdentity.id === "number" && - "type" in asIdentity - ) { - return asIdentity.displayName; - } - const entity = participant as { scoreboardIdentity?: FakeScoreboardIdentity; name?: string }; - if (entity.scoreboardIdentity?.displayName) return entity.scoreboardIdentity.displayName; - if (typeof entity.name === "string") return entity.name; - } - throw new Error("Invalid scoreboard participant"); -} - -function resolveIdentity( - participant: ScoreParticipant, - identities: Map -): FakeScoreboardIdentity { - const key = resolveParticipantKey(participant); - const existing = identities.get(key); - if (existing) return existing; - if (typeof participant === "object" && participant && "displayName" in participant && "id" in participant) { - const id = participant as FakeScoreboardIdentity; - identities.set(key, id); - return id; - } - const fake: FakeScoreboardIdentity = { - displayName: key, - id: nextIdentityId++, - isValid: true, - type: "FakePlayer", - getEntity: () => undefined, - }; - identities.set(key, fake); - return fake; -} - -export function createFakeScoreboard(): FakeScoreboard { - const objectives = new Map(); - /** objectiveId → participantKey → score */ - const scores = new Map>(); - const identities = new Map(); - const displaySlots = new Map(); - - const makeObjective = (id: string, displayName: string): FakeScoreboardObjective => { - if (!scores.has(id)) scores.set(id, new Map()); - const table = scores.get(id)!; - - const objective: FakeScoreboardObjective = { - id, - displayName, - isValid: true, - addScore(participant, scoreToAdd) { - assertValidObjective(objective); - const key = resolveParticipantKey(participant); - resolveIdentity(participant, identities); - const next = (table.get(key) ?? 0) + scoreToAdd; - table.set(key, next); - return next; - }, - getParticipants() { - assertValidObjective(objective); - return [...table.keys()].map((k) => identities.get(k)!).filter(Boolean); - }, - getScore(participant) { - assertValidObjective(objective); - const key = resolveParticipantKey(participant); - return table.has(key) ? table.get(key) : undefined; - }, - getScores() { - assertValidObjective(objective); - return [...table.entries()].map(([k, score]) => ({ - participant: identities.get(k)!, - score, - })); - }, - hasParticipant(participant) { - assertValidObjective(objective); - return table.has(resolveParticipantKey(participant)); - }, - removeParticipant(participant) { - assertValidObjective(objective); - const key = resolveParticipantKey(participant); - return table.delete(key); - }, - setScore(participant, score) { - assertValidObjective(objective); - const key = resolveParticipantKey(participant); - resolveIdentity(participant, identities); - table.set(key, score); - }, - }; - return objective; - }; - - const api: FakeScoreboard = { - addObjective(objectiveId, displayName) { - if (objectives.has(objectiveId)) { - throw new Error(`Objective "${objectiveId}" already exists`); - } - const obj = makeObjective(objectiveId, displayName ?? objectiveId); - objectives.set(objectiveId, obj); - return obj; - }, - getObjective(objectiveId) { - return objectives.get(objectiveId); - }, - getObjectives() { - return [...objectives.values()]; - }, - removeObjective(objectiveId) { - const id = typeof objectiveId === "string" ? objectiveId : objectiveId.id; - const obj = objectives.get(id); - if (!obj) return false; - obj.isValid = false; - objectives.delete(id); - scores.delete(id); - for (const [slot, opts] of [...displaySlots.entries()]) { - if (opts.objective.id === id) displaySlots.delete(slot); - } - return true; - }, - getParticipants() { - return [...identities.values()]; - }, - clearObjectiveAtDisplaySlot(displaySlotId) { - const prev = displaySlots.get(displaySlotId); - displaySlots.delete(displaySlotId); - return prev?.objective; - }, - getObjectiveAtDisplaySlot(displaySlotId) { - return displaySlots.get(displaySlotId); - }, - setObjectiveAtDisplaySlot(displaySlotId, objectiveDisplaySetting) { - const prev = displaySlots.get(displaySlotId); - displaySlots.set(displaySlotId, objectiveDisplaySetting); - return prev?.objective; - }, - _registerIdentity(identity) { - identities.set(identity.displayName, identity); - }, - reset() { - for (const o of objectives.values()) o.isValid = false; - objectives.clear(); - scores.clear(); - identities.clear(); - displaySlots.clear(); - nextIdentityId = 1; - }, - }; - - return api; -} diff --git a/_sandbox_archive/testing/engine/overrides/screen-display.ts b/_sandbox_archive/testing/engine/overrides/screen-display.ts deleted file mode 100644 index 7199d19c..00000000 --- a/_sandbox_archive/testing/engine/overrides/screen-display.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * 假 ScreenDisplay — 标题 / 副标题 / 动作栏可断言;其余 HUD API 仍 L0 硬失败。 - */ - -import { guardUnimplemented } from "../unimplemented-error.js"; - -function displayText(text: unknown): string { - if (typeof text === "string") return text; - if (Array.isArray(text)) return text.map(displayText).join(""); - if (text && typeof text === "object") { - try { - return JSON.stringify(text); - } catch { - return String(text); - } - } - return String(text ?? ""); -} - -export type FakeScreenDisplay = { - readonly isValid: boolean; - /** 沙箱可观测:最近一次 setTitle */ - title: string; - /** 沙箱可观测:副标题(setTitle options 或 updateSubtitle) */ - subtitle: string; - /** 沙箱可观测:动作栏 */ - actionBar: string; - setActionBar(text: unknown): void; - setTitle(title: unknown, options?: { subtitle?: unknown }): void; - updateSubtitle(subtitle: unknown): void; -}; - -export function createFakeScreenDisplay(isOwnerValid: () => boolean): FakeScreenDisplay { - const api: FakeScreenDisplay = { - get isValid() { - return isOwnerValid(); - }, - title: "", - subtitle: "", - actionBar: "", - setActionBar(text) { - if (!isOwnerValid()) throw new Error("InvalidEntityError"); - api.actionBar = displayText(text); - }, - setTitle(title, options) { - if (!isOwnerValid()) throw new Error("InvalidEntityError"); - api.title = displayText(title); - if (options && "subtitle" in options) { - api.subtitle = displayText(options.subtitle); - } - }, - updateSubtitle(subtitle) { - if (!isOwnerValid()) throw new Error("InvalidEntityError"); - api.subtitle = displayText(subtitle); - }, - }; - return guardUnimplemented(api, "ScreenDisplay") as FakeScreenDisplay; -} diff --git a/_sandbox_archive/testing/engine/overrides/system.ts b/_sandbox_archive/testing/engine/overrides/system.ts deleted file mode 100644 index 4156fb5a..00000000 --- a/_sandbox_archive/testing/engine/overrides/system.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * 假 system:run / runTimeout / runInterval + 可 tick 推进;事件 hub 1:1 惰性。 - */ - -import { createEventHub, createEventSignal, type EventSignal } from "./events.js"; -import { guardUnimplemented } from "../unimplemented-error.js"; - -type Scheduled = { - id: number; - kind: "run" | "timeout" | "interval"; - cb: () => void; - dueTick: number; - intervalTicks?: number; -}; - -export type FakeSystem = { - afterEvents: Record>; - beforeEvents: Record>; - currentTick: number; - /** 沙箱非编辑器世界 */ - readonly isEditorWorld: boolean; - run(cb: () => void, ticks?: number): number; - runTimeout(cb: () => void, ticks?: number): number; - runInterval(cb: () => void, ticks?: number): number; - clearRun(id: number): void; - waitTicks(ticks: number): Promise; - tick(n?: number): void; - flush(): void; - reset(): void; -}; - -export function createFakeSystem(): FakeSystem { - let nextId = 1; - let currentTick = 0; - const jobs = new Map(); - - const afterEvents = createEventHub({ - scriptEventReceive: createEventSignal(), - }); - const beforeEvents = createEventHub({ - startup: createEventSignal(), - shutdown: createEventSignal(), - watchdogTerminate: createEventSignal(), - }); - - const runAt = (kind: Scheduled["kind"], cb: () => void, ticks: number, intervalTicks?: number) => { - const id = nextId++; - const job: Scheduled = { - id, - kind, - cb, - dueTick: currentTick + Math.max(0, ticks), - }; - if (intervalTicks !== undefined) job.intervalTicks = intervalTicks; - jobs.set(id, job); - return id; - }; - - const drainDue = () => { - const due = [...jobs.values()].filter((j) => j.dueTick <= currentTick).sort((a, b) => a.id - b.id); - for (const job of due) { - if (!jobs.has(job.id)) continue; - try { - job.cb(); - } catch { - /* 吞掉任务异常 */ - } - if (job.kind === "interval" && job.intervalTicks !== undefined && jobs.has(job.id)) { - job.dueTick = currentTick + Math.max(1, job.intervalTicks); - } else { - jobs.delete(job.id); - } - } - }; - - const api: FakeSystem = { - afterEvents, - beforeEvents, - get currentTick() { - return currentTick; - }, - isEditorWorld: false, - run(cb, ticks = 0) { - return runAt("run", cb, ticks); - }, - runTimeout(cb, ticks = 1) { - return runAt("timeout", cb, ticks); - }, - runInterval(cb, ticks = 1) { - return runAt("interval", cb, Math.max(1, ticks), Math.max(1, ticks)); - }, - clearRun(id) { - jobs.delete(id); - }, - waitTicks(ticks) { - return new Promise((resolve) => { - runAt("timeout", () => resolve(), ticks); - }); - }, - tick(n = 1) { - const steps = Math.max(0, n); - for (let i = 0; i < steps; i++) { - currentTick += 1; - drainDue(); - } - }, - flush() { - let guard = 0; - while (guard++ < 1000) { - const immediate = [...jobs.values()].filter((j) => j.dueTick <= currentTick); - if (immediate.length === 0) break; - drainDue(); - } - }, - reset() { - // 只清调度队列;保留 scriptEvent 等平台级订阅(Command.registerScriptEvent)。 - jobs.clear(); - currentTick = 0; - nextId = 1; - }, - }; - - return guardUnimplemented(api, "system") as FakeSystem; -} diff --git a/_sandbox_archive/testing/engine/overrides/ui-host.ts b/_sandbox_archive/testing/engine/overrides/ui-host.ts deleted file mode 100644 index ab385ee9..00000000 --- a/_sandbox_archive/testing/engine/overrides/ui-host.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * UI 响应队列(与 ActionFormData 共用 getEngine().ui)。 - */ - -import type { FakePlayer } from "./player.js"; - -export type FormResponse = { - canceled?: boolean; - selection?: number; - formValues?: unknown[]; - cancelationReason?: string; - /** CustomForm / MessageBox:DataDrivenScreenClosedReason */ - closeReason?: string; -}; - -type QueueKey = string; - -function keyOf(player: FakePlayer | { id?: string; name?: string }): QueueKey { - return String((player as FakePlayer).id ?? (player as { name?: string }).name ?? "unknown"); -} - -export type FakeUiHost = { - queueResponse(player: FakePlayer | { id?: string; name?: string }, response: FormResponse): void; - takeResponse(player: FakePlayer | { id?: string; name?: string }): FormResponse; - clearPlayer(player: FakePlayer | { id?: string; name?: string }): void; - reset(): void; -}; - -export function createFakeUiHost(): FakeUiHost { - const queues = new Map(); - return { - queueResponse(player, response) { - const k = keyOf(player); - const q = queues.get(k) ?? []; - q.push(response); - queues.set(k, q); - }, - takeResponse(player) { - const k = keyOf(player); - const q = queues.get(k); - if (!q || q.length === 0) { - return { canceled: true, cancelationReason: "UserClosed" }; - } - return q.shift()!; - }, - clearPlayer(player) { - queues.delete(keyOf(player)); - }, - reset() { - queues.clear(); - }, - }; -} diff --git a/_sandbox_archive/testing/engine/overrides/ui.ts b/_sandbox_archive/testing/engine/overrides/ui.ts deleted file mode 100644 index 55264402..00000000 --- a/_sandbox_archive/testing/engine/overrides/ui.ts +++ /dev/null @@ -1,320 +0,0 @@ -/** - * 假 @minecraft/server-ui — 对齐本地 pin `2.2.0-beta.1.26.40-preview.30`。 - * 经典三表单 + CustomForm / MessageBox / Observables / uiManager。 - */ - -import type { FakePlayer } from "./player.js"; -import { getUi } from "../runtime.js"; - -function asText(v: unknown): string { - if (v == null) return ""; - if (typeof v === "string") return v; - if (typeof v === "object" && v && "value" in (v as object)) { - return String((v as { value: unknown }).value ?? ""); - } - return String(v); -} - -export const FormCancelationReason = { - UserBusy: "UserBusy", - UserClosed: "UserClosed", -} as const; - -/** 对齐 pin DataDrivenScreenClosedReason(非 UserClosed)。 */ -export const DataDrivenScreenClosedReason = { - ClientClosed: "ClientClosed", - ServerClosed: "ServerClosed", - UserBusy: "UserBusy", -} as const; - -class FormBase { - titleText = ""; - bodyText = ""; - title(t: unknown) { - this.titleText = asText(t); - return this; - } - body(t: unknown) { - this.bodyText = asText(t); - return this; - } - async show(player: FakePlayer) { - return getUi().takeResponse(player); - } -} - -export class ActionFormData extends FormBase { - buttons: string[] = []; - button(text: unknown, _iconPath?: string) { - this.buttons.push(asText(text)); - return this; - } - divider() { - return this; - } - header(_text: unknown) { - return this; - } - label(_text: unknown) { - return this; - } -} - -export class MessageFormData extends FormBase { - button1Text = ""; - button2Text = ""; - button1(text: unknown) { - this.button1Text = asText(text); - return this; - } - button2(text: unknown) { - this.button2Text = asText(text); - return this; - } -} - -export class ModalFormData extends FormBase { - controls: string[] = []; - submitButtonText = ""; - dropdown(_label: unknown, _items: unknown, _opts?: unknown) { - this.controls.push("dropdown"); - return this; - } - slider(_label: unknown, _min: number, _max: number, _opts?: unknown) { - this.controls.push("slider"); - return this; - } - textField(_label: unknown, _placeholder?: unknown, _opts?: unknown) { - this.controls.push("textField"); - return this; - } - toggle(_label: unknown, _opts?: unknown) { - this.controls.push("toggle"); - return this; - } - divider() { - return this; - } - header(_text: unknown) { - return this; - } - label(_text: unknown) { - return this; - } - submitButton(text: unknown) { - this.submitButtonText = asText(text); - return this; - } -} - -type ObsListener = (v: T) => void; - -class ObservableBase { - private _value: T; - private listeners = new Set>(); - constructor(initial: T) { - this._value = initial; - } - get value(): T { - return this._value; - } - set value(v: T) { - this._value = v; - for (const l of [...this.listeners]) l(v); - } - subscribe(cb: ObsListener): ObsListener { - this.listeners.add(cb); - return cb; - } - unsubscribe(cb: ObsListener): boolean { - return this.listeners.delete(cb); - } -} - -export class ObservableBoolean extends ObservableBase { - constructor(initial = false) { - super(!!initial); - } -} - -export class ObservableNumber extends ObservableBase { - constructor(initial = 0) { - super(Number(initial) || 0); - } -} - -export class ObservableString extends ObservableBase { - constructor(initial = "") { - super(String(initial ?? "")); - } -} - -export class ObservableUIRawMessage extends ObservableBase { - constructor(initial: unknown = {}) { - super(initial); - } -} - -type CustomWidget = - | { kind: "button"; label: string; onClick: () => void } - | { kind: "other"; label?: string }; - -/** - * CustomForm:构造时绑定玩家;show() 无参;可用 queueResponse 驱动关闭原因与按钮。 - * queueResponse.selection = 按钮下标时触发对应 onClick。 - */ -export class CustomForm { - readonly player: FakePlayer; - titleText: string; - private showing = false; - private widgets: CustomWidget[] = []; - private closedByServer = false; - - constructor(player: FakePlayer, title: unknown) { - this.player = player; - this.titleText = asText(title); - } - - button(label: unknown, onClick: () => void, _options?: unknown) { - this.widgets.push({ kind: "button", label: asText(label), onClick }); - return this; - } - closeButton() { - return this; - } - divider(_options?: unknown) { - this.widgets.push({ kind: "other" }); - return this; - } - dropdown(_label: unknown, _value: ObservableNumber, _items: unknown[], _options?: unknown) { - this.widgets.push({ kind: "other" }); - return this; - } - header(_text: unknown, _options?: unknown) { - this.widgets.push({ kind: "other" }); - return this; - } - image(_src: unknown, _pack: unknown, _options?: unknown) { - this.widgets.push({ kind: "other" }); - return this; - } - label(_text: unknown, _options?: unknown) { - this.widgets.push({ kind: "other" }); - return this; - } - slider(_label: unknown, _value: ObservableNumber, _min: number, _max: number, _options?: unknown) { - this.widgets.push({ kind: "other" }); - return this; - } - spacer(_options?: unknown) { - this.widgets.push({ kind: "other" }); - return this; - } - textField(_label: unknown, _value: ObservableString, _options?: unknown) { - this.widgets.push({ kind: "other" }); - return this; - } - toggle(_label: unknown, _value: ObservableBoolean, _options?: unknown) { - this.widgets.push({ kind: "other" }); - return this; - } - - isShowing() { - return this.showing; - } - - close() { - if (!this.showing) { - throw new Error("FormVisibilityError"); - } - this.showing = false; - this.closedByServer = true; - } - - async show(): Promise { - if (this.showing) throw new Error("FormVisibilityError"); - this.showing = true; - this.closedByServer = false; - const res = getUi().takeResponse(this.player); - if (typeof res.selection === "number") { - const buttons = this.widgets.filter((w): w is Extract => w.kind === "button"); - const btn = buttons[res.selection]; - try { - btn?.onClick(); - } catch { - /* 隔离回调异常 */ - } - } - this.showing = false; - if (this.closedByServer || res.closeReason === DataDrivenScreenClosedReason.ServerClosed) { - return DataDrivenScreenClosedReason.ServerClosed; - } - if (res.closeReason) return String(res.closeReason); - if (res.canceled) { - return res.cancelationReason === FormCancelationReason.UserBusy - ? DataDrivenScreenClosedReason.UserBusy - : DataDrivenScreenClosedReason.ClientClosed; - } - return DataDrivenScreenClosedReason.ClientClosed; - } -} - -/** MessageBox:构造绑定玩家;show() → { closeReason, selection? }。 */ -export class MessageBox { - readonly player: FakePlayer; - titleText: string; - bodyText = ""; - button1Text = ""; - button2Text = ""; - private showing = false; - - constructor(player: FakePlayer, title: unknown) { - this.player = player; - this.titleText = asText(title); - } - - body(body: unknown) { - this.bodyText = asText(body); - return this; - } - button1(label: unknown, _tooltip?: unknown) { - this.button1Text = asText(label); - return this; - } - button2(label: unknown, _tooltip?: unknown) { - this.button2Text = asText(label); - return this; - } - isShowing() { - return this.showing; - } - close() { - if (!this.showing) throw new Error("FormVisibilityError"); - this.showing = false; - } - async show(): Promise<{ closeReason: string; selection?: number }> { - if (this.showing) throw new Error("FormVisibilityError"); - this.showing = true; - const res = getUi().takeResponse(this.player); - this.showing = false; - const closeReason = - res.closeReason ?? - (res.canceled - ? res.cancelationReason === FormCancelationReason.UserBusy - ? DataDrivenScreenClosedReason.UserBusy - : DataDrivenScreenClosedReason.ClientClosed - : DataDrivenScreenClosedReason.ClientClosed); - return { - closeReason: String(closeReason), - ...(typeof res.selection === "number" ? { selection: res.selection } : {}), - }; - } -} - -export const uiManager = { - closeAllForms(player: FakePlayer) { - getUi().clearPlayer(player); - }, -}; - -export type { FormResponse } from "./ui-host.js"; diff --git a/_sandbox_archive/testing/engine/overrides/world.ts b/_sandbox_archive/testing/engine/overrides/world.ts deleted file mode 100644 index e647c21d..00000000 --- a/_sandbox_archive/testing/engine/overrides/world.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * 假 world:事件总线(1:1 惰性 hub)+ 玩家列表 + Scoreboard L2 + Dimension L2。 - * 规格 §6「薄」:时间 / 出生点 / sendMessage / getEntity / 动态属性 + 默认三维可查。 - */ - -import { createEventHub, createEventSignal, type EventSignal } from "./events.js"; -import { guardUnimplemented } from "../unimplemented-error.js"; -import type { FakePlayer } from "./player.js"; -import { createFakeScoreboard, type FakeScoreboard } from "./scoreboard.js"; -import { - createFakeDimension, - DEFAULT_DIMENSION_IDS, - resolveDimensionId, - type FakeDimension, - type Vector3Like, -} from "./dimension.js"; -import { resetEntityIdCounter, type FakeEntity } from "./entity.js"; -import { runThinCommand, type FakeCommandResult } from "./command.js"; -import { toDamageSource } from "./damage-source.js"; -import { - createDynamicPropertyBagMethods, - type FakeDynamicPropertyValue, -} from "./dynamic-property.js"; - -export type FakeWorld = { - afterEvents: Record>; - beforeEvents: Record>; - /** L1 默认 */ - allowCheats: boolean; - readonly isHardcore: boolean; - readonly seed: string; - scoreboard: FakeScoreboard; - /** 沙箱可观测:runCommand / playSound */ - commandLog: string[]; - soundLog: string[]; - getDimension(id?: string): FakeDimension; - getPlayers(): FakePlayer[]; - getAllPlayers(): FakePlayer[]; - getEntity(entityId: string): FakeEntity | FakePlayer | undefined; - getAbsoluteTime(): number; - setAbsoluteTime(absoluteTime: number): void; - getDay(): number; - getTimeOfDay(): number; - setTimeOfDay(timeOfDay: number | string): void; - getDefaultSpawnLocation(): Vector3Like; - setDefaultSpawnLocation(location: Vector3Like): void; - sendMessage(message: string | Record): void; - playSound(soundId: string, location: Vector3Like, soundOptions?: unknown): { id: string }; - runCommand(commandString: string): FakeCommandResult; - clearDynamicProperties(): void; - getDynamicProperty(id: string): FakeDynamicPropertyValue | undefined; - getDynamicPropertyIds(): string[]; - getDynamicPropertyTotalByteCount(): number; - setDynamicProperty(id: string, value?: FakeDynamicPropertyValue | null): void; - setDynamicProperties(values: Record): void; - /** 沙箱内部 */ - _players: FakePlayer[]; - addPlayer(player: FakePlayer): void; - removePlayer(id: string): void; - reset(): void; -}; - -const TICKS_PER_DAY = 24000; - -function normalizeTimeOfDay(timeOfDay: number | string): number { - if (typeof timeOfDay === "number" && Number.isFinite(timeOfDay)) { - return ((Math.floor(timeOfDay) % TICKS_PER_DAY) + TICKS_PER_DAY) % TICKS_PER_DAY; - } - const named: Record = { - Day: 1000, - Noon: 6000, - Sunset: 12000, - Night: 13000, - Midnight: 18000, - Sunrise: 23000, - }; - const key = String(timeOfDay); - if (key in named) return named[key]!; - const n = Number(key); - if (Number.isFinite(n)) { - return ((Math.floor(n) % TICKS_PER_DAY) + TICKS_PER_DAY) % TICKS_PER_DAY; - } - return 0; -} - -export function createFakeWorld(): FakeWorld { - const players: FakePlayer[] = []; - const scoreboard = createFakeScoreboard(); - const dims = new Map(); - const dyn = createDynamicPropertyBagMethods(); - const commandLog: string[] = []; - const soundLog: string[] = []; - let absoluteTime = 0; - let defaultSpawn: Vector3Like = { x: 0, y: 64, z: 0 }; - let allowCheats = true; - - const afterEvents = createEventHub({ - worldLoad: createEventSignal(), - playerSpawn: createEventSignal(), - playerJoin: createEventSignal(), - playerLeave: createEventSignal(), - chatSend: createEventSignal(), - entityDie: createEventSignal(), - entityHurt: createEventSignal(), - entityHealthChanged: createEventSignal(), - entityHitEntity: createEventSignal(), - itemUse: createEventSignal(), - playerBreakBlock: createEventSignal(), - playerPlaceBlock: createEventSignal(), - playerInteractWithBlock: createEventSignal(), - playerInteractWithEntity: createEventSignal(), - /** 旧别名;pin 为 playerBreakBlock */ - blockBreak: createEventSignal(), - entitySpawn: createEventSignal(), - playerGameModeChange: createEventSignal(), - }); - const beforeEvents = createEventHub({ - chatSend: createEventSignal(), - playerSpawn: createEventSignal(), - itemUse: createEventSignal(), - playerBreakBlock: createEventSignal(), - playerPlaceBlock: createEventSignal(), - playerInteractWithBlock: createEventSignal(), - playerInteractWithEntity: createEventSignal(), - playerLeave: createEventSignal(), - playerGameModeChange: createEventSignal(), - }); - - const getOrCreateDim = (rawId: string): FakeDimension => { - const canon = resolveDimensionId(rawId); - const existing = dims.get(canon); - if (existing) return existing; - const dim = createFakeDimension(canon, { - getPlayers: () => players.filter((p) => p.dimension.id === canon), - onEntitySpawn: (entity) => { - afterEvents.entitySpawn!.emit({ entity }); - }, - onEntityDie: (entity, options) => { - afterEvents.entityDie!.emit({ - deadEntity: entity, - damageSource: toDamageSource(options), - }); - }, - onEntityHealthChange: (entity, oldValue, newValue) => { - afterEvents.entityHealthChanged!.emit({ entity, oldValue, newValue }); - }, - onEntityHurt: (entity, damage, options) => { - afterEvents.entityHurt!.emit({ - hurtEntity: entity, - damage, - damageSource: toDamageSource(options), - }); - }, - }); - dims.set(canon, dim); - return dim; - }; - - const ensureDefaultDims = () => { - for (const id of DEFAULT_DIMENSION_IDS) getOrCreateDim(id); - }; - - const api: FakeWorld = { - afterEvents, - beforeEvents, - get allowCheats() { - return allowCheats; - }, - set allowCheats(v: boolean) { - allowCheats = Boolean(v); - }, - isHardcore: false, - seed: "sfmc-testing", - scoreboard, - commandLog, - soundLog, - getDimension(id = "minecraft:overworld") { - return getOrCreateDim(id); - }, - getPlayers() { - return [...players]; - }, - getAllPlayers() { - return [...players]; - }, - getEntity(entityId) { - const id = String(entityId ?? ""); - const player = players.find((p) => p.id === id); - if (player) return player; - for (const dim of dims.values()) { - const hit = dim.getEntities().find((e) => e.id === id); - if (hit) return hit; - } - return undefined; - }, - getAbsoluteTime() { - return absoluteTime; - }, - setAbsoluteTime(t) { - absoluteTime = Math.max(0, Math.floor(Number(t) || 0)); - }, - getDay() { - return Math.floor(absoluteTime / TICKS_PER_DAY); - }, - getTimeOfDay() { - return absoluteTime % TICKS_PER_DAY; - }, - setTimeOfDay(timeOfDay) { - const tod = normalizeTimeOfDay(timeOfDay); - absoluteTime = Math.floor(absoluteTime / TICKS_PER_DAY) * TICKS_PER_DAY + tod; - }, - getDefaultSpawnLocation() { - return { ...defaultSpawn }; - }, - setDefaultSpawnLocation(location) { - defaultSpawn = { - x: Number(location.x), - y: Number(location.y), - z: Number(location.z), - }; - }, - sendMessage(message) { - const text = - typeof message === "string" - ? message - : (() => { - try { - return JSON.stringify(message); - } catch { - return String(message); - } - })(); - for (const p of players) p.sendMessage(text); - }, - playSound(soundId, _location, _soundOptions) { - const id = String(soundId ?? ""); - soundLog.push(id); - return { id }; - }, - runCommand(commandString) { - return runThinCommand(commandLog, commandString); - }, - clearDynamicProperties: dyn.clearDynamicProperties, - getDynamicProperty: dyn.getDynamicProperty, - getDynamicPropertyIds: dyn.getDynamicPropertyIds, - getDynamicPropertyTotalByteCount: dyn.getDynamicPropertyTotalByteCount, - setDynamicProperty: dyn.setDynamicProperty, - setDynamicProperties: dyn.setDynamicProperties, - _players: players, - addPlayer(player) { - if (!players.some((p) => p.id === player.id)) { - players.push(player); - if (player.scoreboardIdentity) { - scoreboard._registerIdentity(player.scoreboardIdentity); - } - } - }, - removePlayer(id) { - const i = players.findIndex((p) => p.id === id); - if (i < 0) return; - const [player] = players.splice(i, 1); - if (!player) return; - beforeEvents.playerLeave!.emit({ player, playerId: player.id }); - afterEvents.playerLeave!.emit({ playerName: player.name, playerId: player.id }); - }, - reset() { - players.length = 0; - scoreboard.reset(); - dyn.clearDynamicProperties(); - commandLog.length = 0; - soundLog.length = 0; - absoluteTime = 0; - defaultSpawn = { x: 0, y: 64, z: 0 }; - allowCheats = true; - for (const d of dims.values()) d.reset(); - dims.clear(); - resetEntityIdCounter(); - ensureDefaultDims(); - ( - afterEvents as unknown as { - _clearAll(): void; - } - )._clearAll(); - ( - beforeEvents as unknown as { - _clearAll(): void; - } - )._clearAll(); - }, - }; - - ensureDefaultDims(); - - return guardUnimplemented(api, "world") as FakeWorld; -} - diff --git a/_sandbox_archive/testing/engine/runtime.ts b/_sandbox_archive/testing/engine/runtime.ts deleted file mode 100644 index 59069478..00000000 --- a/_sandbox_archive/testing/engine/runtime.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * 假引擎进程内单例(挂 globalThis,避免 testing 与 mc-bridge 双份 bundle 状态分裂)。 - */ - -import { createFakeSystem, type FakeSystem } from "./overrides/system.js"; -import { createFakeWorld, type FakeWorld } from "./overrides/world.js"; -import { createEnginePlayer, type FakePlayer, type FakePlayerInit } from "./overrides/player.js"; -import { createFakeUiHost, type FakeUiHost, type FormResponse } from "./overrides/ui-host.js"; -import { UnimplementedMinecraftApiError } from "./unimplemented-error.js"; -import { BlockPermutation, Dimension } from "./overrides/dimension.js"; -import { Entity } from "./overrides/entity.js"; -import { ItemStack, EntityInventoryComponent } from "./overrides/inventory.js"; -import { EntityHealthComponent } from "./overrides/health.js"; -import { Effect } from "./overrides/effect.js"; - -export type EngineState = { - system: FakeSystem; - world: FakeWorld; - ui: FakeUiHost; -}; - -type G = typeof globalThis & { __sfmcTestingEngine?: EngineState }; - -function store(): G { - return globalThis as G; -} - -export function getEngine(): EngineState { - const g = store(); - if (!g.__sfmcTestingEngine) { - g.__sfmcTestingEngine = { - system: createFakeSystem(), - world: createFakeWorld(), - ui: createFakeUiHost(), - }; - } - return g.__sfmcTestingEngine; -} - -/** 就地复位(保持对象身份)。 */ -export function resetEngine(): EngineState { - const eng = getEngine(); - eng.system.reset(); - eng.world.reset(); - eng.ui.reset(); - return eng; -} - -export function disposeEngine(): void { - if (!store().__sfmcTestingEngine) return; - resetEngine(); -} - -export { createEnginePlayer }; -export type { FakePlayer, FakePlayerInit, FormResponse, FakeSystem, FakeWorld, FakeUiHost }; - -export const PlayerPermissionLevel = { - Visitor: 0, - Member: 1, - Operator: 2, - Custom: 3, -} as const; - -/** 对齐 pin `@minecraft/server` GameMode 枚举字面量。 */ -export const GameMode = { - Survival: "Survival", - Creative: "Creative", - Adventure: "Adventure", - Spectator: "Spectator", -} as const; - -function unimplementedCtor(name: string) { - return class { - constructor() { - throw new UnimplementedMinecraftApiError(`new ${name}()`); - } - }; -} - -export function getWorld(): FakeWorld { - return getEngine().world; -} - -export function getSystem(): FakeSystem { - return getEngine().system; -} - -export function getUi(): FakeUiHost { - return getEngine().ui; -} - -export function createServerExports() { - return { - world: getWorld(), - system: getSystem(), - Player: unimplementedCtor("Player"), - PlayerPermissionLevel, - GameMode, - ItemStack, - Entity, - BlockComponentTypes: {}, - BlockPermutation, - Dimension, - EntityInventoryComponent, - EntityHealthComponent, - Effect, - EntityInitializationCause: {}, - }; -} diff --git a/_sandbox_archive/testing/engine/unimplemented-error.ts b/_sandbox_archive/testing/engine/unimplemented-error.ts deleted file mode 100644 index 8c5ae422..00000000 --- a/_sandbox_archive/testing/engine/unimplemented-error.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * 假引擎未实现 API 硬失败(全表面:无 allowlist 裁剪)。 - */ - -export class UnimplementedMinecraftApiError extends Error { - constructor(apiPath: string) { - super( - `sfmc-testing: 未实现的 Minecraft API「${apiPath}」。` + - `该符号可 import,但沙箱尚未接线语义;请缩小用例或等待 L2/L3 加深。` - ); - this.name = "UnimplementedMinecraftApiError"; - } -} - -/** - * 对实例做全表面代理:已实现成员可访问,其余属性访问抛硬失败(禁止安静 undefined)。 - */ -export function guardUnimplemented(target: T, path: string): T { - return new Proxy(target, { - get(obj, prop, receiver) { - if (typeof prop === "symbol") return Reflect.get(obj, prop, receiver); - if (prop === "then") return undefined; /* 避免被当成 Thenable */ - if (prop in obj) return Reflect.get(obj, prop, receiver); - throw new UnimplementedMinecraftApiError(`${path}.${prop}`); - }, - }); -} diff --git a/_sandbox_archive/testing/events-drive.ts b/_sandbox_archive/testing/events-drive.ts deleted file mode 100644 index b55b7d22..00000000 --- a/_sandbox_archive/testing/events-drive.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * sb.events — 按路径 1:1 emit(hub + Event 类型来自 playground-meta) - */ - -import { PLAYGROUND_META } from "./engine/generated/playground-meta.js"; -import type { FakeWorld } from "./engine/overrides/world.js"; -import type { FakeSystem } from "./engine/overrides/system.js"; -import type { EventEmitResult, EventSignal } from "./engine/overrides/events.js"; -import type { SandboxObjects } from "./objects.js"; - -type Host = { world: FakeWorld; system: FakeSystem; objects?: SandboxObjects }; - -export type EventsEmitMeta = { - path: string; - listeners: number; - errors: { message: string; stack?: string }[]; -}; - -function resolveSignal(host: Host, path: string): EventSignal { - const parts = path.split("."); - if (parts.length !== 3) { - throw new Error(`invalid event path: ${path}`); - } - const root = parts[0]!; - const hub = parts[1]!; - const signal = parts[2]!; - const bag = - root === "world" - ? (host.world as unknown as Record>>)[hub] - : root === "system" - ? (host.system as unknown as Record>>)[hub] - : undefined; - if (!bag) throw new Error(`unknown event root: ${path}`); - const sig = bag[signal]; - if (!sig || typeof sig.emit !== "function") { - throw new Error(`unknown or non-emit signal: ${path}`); - } - return sig; -} - -function knownPath(path: string): boolean { - return Object.prototype.hasOwnProperty.call(PLAYGROUND_META.eventTypes, path); -} - -/** payload 树是否含 $ref(有则须解析成实例;无则保持同一引用供 cancel 等原地改写)。 */ -function payloadNeedsResolve(value: unknown): boolean { - if (value == null || typeof value !== "object") return false; - if (Array.isArray(value)) return value.some(payloadNeedsResolve); - const o = value as Record; - if (typeof o.$ref === "string") return true; - return Object.values(o).some(payloadNeedsResolve); -} - -export function createEventsDrive(host: Host) { - let lastMeta: EventsEmitMeta | null = null; - - return { - meta: PLAYGROUND_META.events, - eventTypes: PLAYGROUND_META.eventTypes, - /** 最近一次 emit 的订阅数 / 回调错误(供 playground lastEmit) */ - lastMeta(): EventsEmitMeta | null { - return lastMeta; - }, - /** 当前信号上已 subscribe 的回调数 */ - listenerCount(path: string): number { - if (!knownPath(path)) { - throw new Error(`event not in PLAYGROUND_META: ${path}`); - } - return resolveSignal(host, path).size(); - }, - /** - * 已有 listener 的事件 path 摘要(只扫 hub 已物化信号,不惰性造空信号)。 - * 含模块 registerEvents 与宿主 chat→命令桥等。 - */ - subscribedPaths(): { path: string; listeners: number }[] { - const out: { path: string; listeners: number }[] = []; - const hubs: { root: "world" | "system"; name: string; bag: unknown }[] = [ - { root: "world", name: "afterEvents", bag: host.world.afterEvents }, - { root: "world", name: "beforeEvents", bag: host.world.beforeEvents }, - { root: "system", name: "afterEvents", bag: host.system.afterEvents }, - { root: "system", name: "beforeEvents", bag: host.system.beforeEvents }, - ]; - for (const h of hubs) { - const bag = h.bag as { _signals?: () => Map> }; - const signals = typeof bag?._signals === "function" ? bag._signals() : null; - if (!signals) continue; - for (const [signal, sig] of signals) { - const n = sig.size(); - if (n <= 0) continue; - const path = `${h.root}.${h.name}.${signal}`; - if (!knownPath(path)) continue; - out.push({ path, listeners: n }); - } - } - return out.sort((a, b) => a.path.localeCompare(b.path)); - }, - /** - * 路径如 world.afterEvents.playerJoin; - * 同步调用该信号全部订阅回调,返回 emit 后的事件对象(可含 cancel 等副作用)。 - */ - emit(path: string, payload: unknown = {}): unknown { - if (!knownPath(path)) { - throw new Error(`event not in PLAYGROUND_META: ${path}`); - } - let resolved = payload; - if (host.objects && payload && typeof payload === "object" && payloadNeedsResolve(payload)) { - resolved = host.objects.resolve(payload); - } - const result: EventEmitResult = resolveSignal(host, path).emit(resolved); - lastMeta = { - path, - listeners: result.listeners, - errors: result.errors, - }; - return resolved; - }, - /** 信号对应的 Event 类名 */ - eventType(path: string): string | undefined { - const e = PLAYGROUND_META.eventTypes[path as keyof typeof PLAYGROUND_META.eventTypes]; - return e?.eventType; - }, - paths(): string[] { - return Object.keys(PLAYGROUND_META.eventTypes).sort(); - }, - }; -} - -export type SandboxEvents = ReturnType; diff --git a/_sandbox_archive/testing/fake-db.ts b/_sandbox_archive/testing/fake-db.ts deleted file mode 100644 index 17c80863..00000000 --- a/_sandbox_archive/testing/fake-db.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Fake db:tx.call stub(从原 testing/index 拆出)。 - */ - -export interface FakeDbStub { - provides?: Record unknown>; -} - -export interface FakeDbTx { - calls: Array<{ name: string; input: unknown; result?: unknown; error?: string | undefined }>; - call(name: string, input?: unknown): Promise; -} - -export interface FakeDb { - reset(): void; - tx(cb: (tx: FakeDbTx) => Promise | T): Promise; - stubServices(opts: FakeDbStub): void; - /** 便于断言:最近一次 tx 内全部 call。 */ - readonly calls: FakeDbTx["calls"]; -} - -export function createFakeDb(initial?: FakeDbStub): FakeDb { - const txLog: FakeDbTx["calls"] = []; - let stubs = initial?.provides ?? {}; - - return { - get calls() { - return txLog; - }, - reset() { - txLog.length = 0; - }, - stubServices(opts) { - stubs = opts.provides ?? {}; - }, - async tx(cb: (tx: FakeDbTx) => Promise | T): Promise { - const tx: FakeDbTx = { - calls: txLog, - async call(name: string, input?: unknown): Promise { - const stub = stubs[name]; - let result: unknown; - let error: string | undefined; - try { - if (!stub) throw new Error(`fake-db: no stub for service "${name}"`); - result = stub(input); - } catch (e) { - error = (e as Error).message; - } - txLog.push({ name, input, result, error }); - if (error) throw new Error(error); - return result as T; - }, - }; - return cb(tx); - }, - }; -} diff --git a/_sandbox_archive/testing/fixture.ts b/_sandbox_archive/testing/fixture.ts deleted file mode 100644 index 45fd33dd..00000000 --- a/_sandbox_archive/testing/fixture.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * 脚本沙箱模块夹具 — 作者可见的 configs / 权限 / 假 DB 意图。 - * 由 playground-host fixture.get / fixture.apply 与 createSandbox 共用。 - */ - -import fs from "node:fs"; -import path from "node:path"; -import { - ConfigManager, - mergeSemanticV3, - migrateV2toV3, - type ManifestV3, - type ManifestV3Semantic, - validateManifestV3, -} from "@sfmc-bds/sdk/module-loader"; -import type { MemoryConfigsAll, MemoryDataAdapter } from "./host/memory-data-adapter.js"; -import type { FakeDb } from "./fake-db.js"; -import type { FakePlayer } from "./engine/overrides/player.js"; -import { PlayerPermissionLevel } from "./engine/runtime.js"; - -/** Permission 常见等级(对齐 sapi/runtime Permission)。 */ -export const FIXTURE_PERMISSION_LEVELS = { - Any: 0, - Member: 1, - OP: 2, - Admin: 3, -} as const; - -/** 作者夹具意图(重置场景后宿主可保留并重新应用)。 */ -export type SandboxFixtureIntent = { - /** settings 键值覆盖(合并进内存 configs)。 */ - settings?: Record; - /** - * 权限表覆盖(整表替换)。 - * 与 treatPlayersAsOp 同时给出时,先写本表再为场景玩家补 OP。 - */ - permissions?: Array<{ player_name: string; level: number }>; - /** 场景中已有玩家一律视为 OP(ConfigManager 覆盖 + FakePlayer.permissionLevel)。 */ - treatPlayersAsOp?: boolean; - /** 模块 enabled;写入 modules[] 对应项。缺省不改。 */ - enabled?: boolean; - /** 应用时清空假 DB 调用日志(无完整种子 API)。 */ - clearDb?: boolean; - /** - * 模块 manifest v3 语义字段(仅补强,不覆盖模块自身的 semantic)。 - * 留空表示「不注入」;模块 manifest 是 v2 时由 sandbox 自动 migrate 到 v3。 - */ - semantic?: ManifestV3Semantic; -}; - -/** fixture.get 返回的可读快照。 */ -export type SandboxFixtureSnapshot = { - module: { id: string; root?: string } | null; - moduleRoot: string | null; - enabled: boolean | null; - configs: MemoryConfigsAll; - intent: SandboxFixtureIntent; - /** 假 DB 最近 call 条数(无完整种子;高级请用测试代码 stubServices)。 */ - dbCallCount: number; - note: string; -}; - -export type ApplyFixtureContext = { - adapter: MemoryDataAdapter; - db: FakeDb; - moduleId: string | null; - getPlayers: () => FakePlayer[]; -}; - -function mergePermissions( - base: Array<{ player_name: string; level: number }>, - intent: SandboxFixtureIntent, - players: FakePlayer[] -): Array<{ player_name: string; level: number }> { - const map = new Map(); - for (const p of base) { - if (p?.player_name) map.set(p.player_name, Number(p.level)); - } - if (intent.permissions) { - map.clear(); - for (const p of intent.permissions) { - if (p?.player_name) map.set(p.player_name, Number(p.level)); - } - } - if (intent.treatPlayersAsOp) { - for (const pl of players) { - map.set(pl.name, FIXTURE_PERMISSION_LEVELS.OP); - } - } - return [...map.entries()].map(([player_name, level]) => ({ player_name, level })); -} - -function setModuleEnabled(configs: MemoryConfigsAll, moduleId: string, enabled: boolean): void { - const modules = configs.modules ?? []; - let hit = false; - for (const m of modules) { - const id = String(m.id || m.module_id || "").trim(); - if (id === moduleId) { - m.enabled = enabled; - hit = true; - } - } - if (!hit && moduleId) { - const configKey = moduleId.includes("-") - ? moduleId.slice(moduleId.indexOf("-") + 1).replace(/-/g, "_") - : moduleId; - modules.push({ id: moduleId, configKey, enabled, installed: true }); - } - configs.modules = modules; -} - -/** - * 将夹具意图写入内存适配器并刷新 ConfigManager。 - * enabled 变更只刷新启停缓存(不重新 boot);完整重装请 reset 场景。 - */ -export async function applyFixtureIntent( - ctx: ApplyFixtureContext, - intent: SandboxFixtureIntent -): Promise { - const snap = ctx.adapter.getSnapshot(); - const players = ctx.getPlayers(); - - if (intent.settings !== undefined) { - snap.settings = { ...(snap.settings ?? {}), ...structuredClone(intent.settings) }; - } - - if (intent.enabled !== undefined && ctx.moduleId) { - setModuleEnabled(snap, ctx.moduleId, intent.enabled); - } - - snap.permissions = mergePermissions(snap.permissions ?? [], intent, players); - - ctx.adapter.replace(snap); - await ConfigManager.loadAll(); - if (intent.enabled !== undefined) { - await ConfigManager.refreshModules(); - } - - if (intent.treatPlayersAsOp) { - for (const pl of players) { - pl.playerPermissionLevel = PlayerPermissionLevel.Operator; - } - } - - if (intent.clearDb) { - ctx.db.reset(); - } - - const out: SandboxFixtureIntent = {}; - if (intent.settings) out.settings = structuredClone(intent.settings); - if (intent.permissions) out.permissions = structuredClone(intent.permissions); - if (intent.treatPlayersAsOp !== undefined) out.treatPlayersAsOp = intent.treatPlayersAsOp; - if (intent.enabled !== undefined) out.enabled = intent.enabled; - if (intent.clearDb !== undefined) out.clearDb = intent.clearDb; - /* semantic 不参与 ConfigManager;只透传以便 snapshot 用。 */ - if (intent.semantic !== undefined) out.semantic = structuredClone(intent.semantic); - return out; -} - -/** 从 createSandbox 初始 opts 合成 configs(夹具 settings/permissions/enabled)。 */ -export function configsFromFixtureIntent( - base: MemoryConfigsAll, - intent: SandboxFixtureIntent | undefined, - moduleId: string | undefined -): MemoryConfigsAll { - const out = structuredClone(base); - if (!intent) return out; - if (intent.settings) { - out.settings = { ...(out.settings ?? {}), ...structuredClone(intent.settings) }; - } - if (intent.permissions) { - out.permissions = structuredClone(intent.permissions); - } - if (intent.enabled !== undefined && moduleId) { - setModuleEnabled(out, moduleId, intent.enabled); - } - return out; -} - -/** - * 从 moduleRoot 读 sapi/manifest.json 并解析为 v3 manifest。 - * - 文件缺失 / 解析失败 → 返回 null(沙箱可继续,但 moduleManifest 为 null); - * - schemaVersion ∈ {2, 3}; - * - v2 → 自动 migrate 到 v3; - * - v3 → 走 validateManifestV3;校验失败时剥掉 semantic 但保留 v3 顶层字段。 - * - fixture.semantic 非空时合并进 v3.semantic(patch 不覆盖 base)。 - */ -export function resolveModuleManifest( - moduleRoot: string | undefined, - fixtureSemantic: ManifestV3Semantic | undefined -): ManifestV3 | null { - if (!moduleRoot) return null; - const manifestPath = path.join(moduleRoot, "sapi", "manifest.json"); - let raw: unknown; - try { - const text = fs.readFileSync(manifestPath, "utf8"); - raw = JSON.parse(text); - } catch { - return null; - } - let v3: ManifestV3; - if (raw && typeof raw === "object" && (raw as { schemaVersion?: unknown }).schemaVersion === 3) { - const validated = validateManifestV3(raw); - v3 = validated.ok - ? validated.manifest - : /* 校验失败:保留必需字段,去掉 semantic,避免把坏数据塞进沙箱。 */ - stripUnsafeSemantic(raw); - } else if (raw && typeof raw === "object" && (raw as { schemaVersion?: unknown }).schemaVersion === 2) { - v3 = migrateV2toV3(raw as never); - } else { - return null; - } - if (!fixtureSemantic) return v3; - const mergedSemantic = mergeSemanticV3(v3.semantic, fixtureSemantic); - return mergedSemantic ? { ...v3, semantic: mergedSemantic } : v3; -} - -/** v3 校验失败时,剥掉 semantic 但保留 v3 顶层字段,避免沙箱读到坏数据。 */ -function stripUnsafeSemantic(raw: unknown): ManifestV3 { - const r = raw as Record; - const out: ManifestV3 = { - schemaVersion: 3, - id: typeof r.id === "string" ? r.id : "", - name: typeof r.name === "string" ? r.name : "", - configKey: typeof r.configKey === "string" ? r.configKey : "", - requires: Array.isArray(r.requires) ? r.requires.filter((s): s is string => typeof s === "string") : [], - permissions: Array.isArray(r.permissions) - ? r.permissions.filter((s): s is string => typeof s === "string") - : [], - ...(r.services && typeof r.services === "object" - ? { services: r.services as ManifestV3["services"] } - : {}), - }; - return out; -} - -export function buildFixtureSnapshot(opts: { - module: { id: string; root?: string } | null; - moduleRoot: string | null; - enabled: boolean | null; - adapter: MemoryDataAdapter; - intent: SandboxFixtureIntent; - db: FakeDb; -}): SandboxFixtureSnapshot { - return { - module: opts.module, - moduleRoot: opts.moduleRoot, - enabled: opts.enabled, - configs: opts.adapter.getSnapshot(), - intent: structuredClone(opts.intent), - dbCallCount: opts.db.calls.length, - note: "假 DB 仅支持清空调用日志;服务种子请用 createSandbox({ db: { provides } }) 或测试代码 stubServices", - }; -} diff --git a/_sandbox_archive/testing/host/memory-data-adapter.ts b/_sandbox_archive/testing/host/memory-data-adapter.ts deleted file mode 100644 index 0d47d7e5..00000000 --- a/_sandbox_archive/testing/host/memory-data-adapter.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * 内存 DataAdapter — 测试沙箱用,不连 db-server。 - * 可变:夹具面板可 patch 后经 ConfigManager.loadAll / refreshModules 刷新。 - */ - -import type { DataAdapter } from "@sfmc-bds/sdk/module-loader"; - -export type MemoryConfigsAll = { - modules?: Array<{ - id?: string; - module_id?: string; - config_key?: string; - configKey?: string; - name?: string; - enabled?: boolean; - installed?: boolean; - }>; - module_tokens?: Record; - settings?: Record; - permissions?: Array<{ player_name: string; level: number }>; -}; - -function clonePayload(all: MemoryConfigsAll): MemoryConfigsAll { - return { - modules: structuredClone(all.modules ?? []), - module_tokens: structuredClone(all.module_tokens ?? {}), - settings: structuredClone(all.settings ?? {}), - permissions: structuredClone(all.permissions ?? []), - }; -} - -export type MemoryDataAdapter = DataAdapter & { - /** 当前内存 configs/all 快照副本。 */ - getSnapshot(): MemoryConfigsAll; - /** 整表替换。 */ - replace(all: MemoryConfigsAll): void; - /** 浅合并顶层域(modules / settings / permissions / module_tokens)。 */ - patch(partial: Partial): void; -}; - -export function createMemoryDataAdapter(all: MemoryConfigsAll = {}): MemoryDataAdapter { - let payload = clonePayload(all); - return { - getSnapshot() { - return clonePayload(payload); - }, - replace(next) { - payload = clonePayload(next); - }, - patch(partial) { - if (partial.modules !== undefined) payload.modules = structuredClone(partial.modules); - if (partial.module_tokens !== undefined) { - payload.module_tokens = structuredClone(partial.module_tokens); - } - if (partial.settings !== undefined) { - payload.settings = { ...(payload.settings ?? {}), ...structuredClone(partial.settings) }; - } - if (partial.permissions !== undefined) { - payload.permissions = structuredClone(partial.permissions); - } - }, - async getAllConfigs() { - return JSON.stringify(payload); - }, - async getModules() { - return JSON.stringify({ modules: payload.modules }); - }, - setAuthToken(_token: string) { - /* noop */ - }, - async checkHealth() { - /* noop */ - }, - }; -} diff --git a/_sandbox_archive/testing/index.ts b/_sandbox_archive/testing/index.ts deleted file mode 100644 index fe771423..00000000 --- a/_sandbox_archive/testing/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @sfmc-bds/sdk/testing — 模块测试沙箱(假引擎 + createSandbox) - * - * 须配合 `--import @sfmc-bds/sdk/testing/minecraft-loader`,使 - * `import from "@minecraft/server"` 落到可控假实现。 - */ - -export type { FakePlayer, FakePlayerInit } from "./engine/overrides/player.js"; -export { createEnginePlayer as createFakePlayer } from "./engine/overrides/player.js"; - -export type { FakeWorld } from "./engine/overrides/world.js"; -export { createFakeWorld } from "./engine/overrides/world.js"; - -export type { FakeDb, FakeDbStub, FakeDbTx } from "./fake-db.js"; -export { createFakeDb } from "./fake-db.js"; - -export type { RunLifecycleOpts } from "./lifecycle.js"; -export { runLifecycle, runCleanup } from "./lifecycle.js"; - -export type { - CreateSandboxOpts, - Sandbox, - SandboxEmit, - MemoryConfigsAll, - MemoryDataAdapter, - SandboxProgressStep, - SandboxFixtureIntent, -} from "./sandbox.js"; -export { createSandbox } from "./sandbox.js"; - -export type { SandboxFixtureSnapshot } from "./fixture.js"; -export { - applyFixtureIntent, - buildFixtureSnapshot, - configsFromFixtureIntent, - FIXTURE_PERMISSION_LEVELS, -} from "./fixture.js"; -export { createMemoryDataAdapter } from "./host/memory-data-adapter.js"; - -export { loadModuleDescriptor, resolveModuleEntry } from "./load-module.js"; - -export { - installModuleLogBridge, - shouldForwardModuleLog, -} from "./module-log-bridge.js"; -export type { ModuleLogBridgeHandle, ModuleLogNotify } from "./module-log-bridge.js"; - -export { PLAYGROUND_META } from "./engine/generated/playground-meta.js"; -export type { SandboxObjects, SandboxObjectHandle, SandboxObjectKind } from "./objects.js"; -export type { SandboxEvents } from "./events-drive.js"; - -export { PlayerPermissionLevel } from "./engine/runtime.js"; - -export type { FormResponse } from "./engine/overrides/ui-host.js"; -export { UnimplementedMinecraftApiError } from "./engine/unimplemented-error.js"; - -/** 断言某 fake player 收到含子串的某类 Msg 消息。 */ -export function assertMsg( - player: { log: string[] }, - includes: string, - prefix = "§" -): boolean { - return player.log.some((line) => line.includes(prefix) && line.includes(includes)); -} - -/** 把模块 entry 文件加载进 Node 测试运行时。 */ -export async function importModuleEntry(path: string): Promise { - return import(path); -} - -/** API 占位:Msg.* 走 player.sendMessage,无需额外 hook。 */ -export function interceptMsgFor(player: { log: string[] }): void { - void player; -} diff --git a/_sandbox_archive/testing/lifecycle.ts b/_sandbox_archive/testing/lifecycle.ts deleted file mode 100644 index f676f17c..00000000 --- a/_sandbox_archive/testing/lifecycle.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * runLifecycle / runCleanup — 旁路 ConfigManager,直接调 hooks。 - * 默认集成路径请用 createSandbox(宿主分相);本文件仅供钩子单测。 - */ - -import type { ModuleDescriptor } from "@sfmc-bds/sdk/module-loader"; -import type { FakeWorld } from "./engine/overrides/world.js"; -import type { FakeDb } from "./fake-db.js"; -import type { FakePlayer } from "./engine/overrides/player.js"; - -export interface RunLifecycleOpts { - afterWorldLoad?: boolean; - world?: FakeWorld; - db?: FakeDb; - defaultPlayer?: FakePlayer; -} - -export async function runLifecycle( - descriptor: ModuleDescriptor, - opts: RunLifecycleOpts = {} -): Promise<{ ok: boolean; error?: unknown }> { - try { - descriptor.lifecycle.registerPermissions?.(); - descriptor.lifecycle.registerCommands?.(); - descriptor.lifecycle.registerEvents?.(); - if (opts.afterWorldLoad !== false && !descriptor.afterWorldLoad) { - const r = descriptor.lifecycle.init?.(); - if (r && typeof (r as Promise).then === "function") { - await r; - } - } else if (opts.afterWorldLoad === true && descriptor.afterWorldLoad) { - const r = descriptor.lifecycle.init?.(); - if (r && typeof (r as Promise).then === "function") { - await r; - } - } - return { ok: true }; - } catch (e) { - return { ok: false, error: e }; - } -} - -export async function runCleanup(descriptor: ModuleDescriptor): Promise<{ ok: boolean; error?: unknown }> { - try { - const r = descriptor.lifecycle.cleanup?.(); - if (r && typeof (r as Promise).then === "function") { - await r; - } - return { ok: true }; - } catch (e) { - return { ok: false, error: e }; - } -} diff --git a/_sandbox_archive/testing/load-module.ts b/_sandbox_archive/testing/load-module.ts deleted file mode 100644 index 202c99ba..00000000 --- a/_sandbox_archive/testing/load-module.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * 从模块根装载真实 SAPI 入口(sapi/src/index.ts),取得 ModuleDescriptor。 - * 对齐扩展 / playground-host 消费面;非 allowlist 玩具入口。 - */ - -import fs from "node:fs"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; -import type { ModuleDescriptor } from "@sfmc-bds/sdk/module-loader"; - -const ENTRY_CANDIDATES = ["sapi/src/index.ts", "sapi/src/index.js", "sapi/src/index.mjs"] as const; - -/** 解析模块入口文件路径;找不到则抛错。 */ -export function resolveModuleEntry(moduleRoot: string): string { - const root = path.resolve(moduleRoot); - for (const rel of ENTRY_CANDIDATES) { - const full = path.join(root, rel); - if (fs.existsSync(full)) return full; - } - throw new Error( - `找不到模块入口(需 ${ENTRY_CANDIDATES.join(" / ")}): ${root}` - ); -} - -function isDescriptor(v: unknown): v is ModuleDescriptor { - if (!v || typeof v !== "object") return false; - const d = v as ModuleDescriptor; - return typeof d.id === "string" && !!d.id.trim() && !!d.lifecycle && typeof d.lifecycle === "object"; -} - -/** - * 动态 import 模块入口并返回 DESCRIPTOR。 - * 使用 cache-bust 查询串,保证「重置场景」能拿到最新源码。 - * 入口副作用可能已 ModuleRegistry.register;调用方在 reset 之后应再 register 一次(按 id 去重)。 - */ -export async function loadModuleDescriptor(moduleRoot: string): Promise { - const entryFs = resolveModuleEntry(moduleRoot); - const url = `${pathToFileURL(entryFs).href}?sfmc=${Date.now()}`; - let mod: Record; - try { - mod = (await import(url)) as Record; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - throw new Error( - `装载模块入口失败: ${entryFs}\n${msg}\n提示:.ts 入口需 --import tsx/esm 或 Node strip-types`, - { cause: e instanceof Error ? e : undefined } - ); - } - if (!isDescriptor(mod.DESCRIPTOR)) { - throw new Error( - `模块入口须 export const DESCRIPTOR: ModuleDescriptor(与 ModuleRegistry.register 同源): ${entryFs}` - ); - } - return mod.DESCRIPTOR; -} diff --git a/_sandbox_archive/testing/minecraft-loader.mjs b/_sandbox_archive/testing/minecraft-loader.mjs deleted file mode 100644 index 9c83250a..00000000 --- a/_sandbox_archive/testing/minecraft-loader.mjs +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env node -// @ts-check -/** - * @sfmc-bds/sdk/testing/minecraft-loader - * - * Node ESM loader:把 @minecraft/server|server-ui|… 指到 SDK 假引擎桥; - * 并把 `@sfmc-bds/sdk` / 子路径钉到本 loader 所属包根,避免模块仓 - * node_modules 与宿主各装一份 SDK 导致 Command/Permission 双实例。 - * vanilla-data 走真实包。用法: - * node --test --import @sfmc-bds/sdk/testing/minecraft-loader --import tsx/esm … - */ - -import { register } from "node:module"; -import { createRequire } from "node:module"; -import { pathToFileURL, fileURLToPath } from "node:url"; -import path from "node:path"; -import fs from "node:fs"; - -register(import.meta.url); - -const here = path.dirname(fileURLToPath(import.meta.url)); -/** dist/esm/testing → 包根(含 package.json / exports) */ -const sdkRoot = path.resolve(here, "../../.."); -const sdkRequire = createRequire(path.join(sdkRoot, "package.json")); - -/** @type {Record} */ -const BRIDGES = { - "@minecraft/server": "mc-bridge-server.js", - "@minecraft/server-ui": "mc-bridge-ui.js", - "@minecraft/server-net": "mc-bridge-net.js", - "@minecraft/server-admin": "mc-bridge-admin.js", - "@minecraft/diagnostics": "mc-bridge-diagnostics.js", -}; - -/** - * @param {string} file - */ -function bridgeUrl(file) { - const candidates = [path.join(here, file), path.join(here, "engine", file)]; - for (const c of candidates) { - if (fs.existsSync(c)) return pathToFileURL(c).href; - } - return pathToFileURL(path.join(here, file)).href; -} - -/** - * 将 `@sfmc-bds/sdk…` 解析到本 loader 所属包(单一 Command/Permission/Registry)。 - * @param {string} specifier - * @returns {string | null} file URL - */ -function pinSfmcSdk(specifier) { - if (specifier !== "@sfmc-bds/sdk" && !specifier.startsWith("@sfmc-bds/sdk/")) { - return null; - } - try { - const resolved = sdkRequire.resolve(specifier); - return pathToFileURL(resolved).href; - } catch { - return null; - } -} - -/** - * @param {string} specifier - * @param {{ parentURL?: string }} context - * @param {(s: string, c: object) => Promise<{ url: string; shortCircuit?: boolean }>} nextResolve - */ -export async function resolve(specifier, context, nextResolve) { - if (specifier === "@minecraft/vanilla-data" || specifier.startsWith("@minecraft/vanilla-data/")) { - return nextResolve(specifier, context); - } - if (specifier === "@minecraft/common" || specifier.startsWith("@minecraft/common/")) { - /* common 仅类型;给空模块避免炸 */ - return { url: "data:text/javascript,export default {};", shortCircuit: true }; - } - const bridge = BRIDGES[specifier]; - if (bridge) { - return { url: bridgeUrl(bridge), shortCircuit: true }; - } - if (specifier.startsWith("@minecraft/")) { - return { - url: `data:text/javascript,throw new Error(${JSON.stringify( - `sfmc-testing: 未支持的包 ${specifier}(仅 server/server-ui/net/admin/diagnostics/vanilla-data)` - )});`, - shortCircuit: true, - }; - } - const pinned = pinSfmcSdk(specifier); - if (pinned) { - return { url: pinned, shortCircuit: true }; - } - return nextResolve(specifier, context); -} diff --git a/_sandbox_archive/testing/module-log-bridge.ts b/_sandbox_archive/testing/module-log-bridge.ts deleted file mode 100644 index c7dbec0d..00000000 --- a/_sandbox_archive/testing/module-log-bridge.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * 沙箱内模块 console / Msg → 宿主 log 通知(source=模块 id)。 - * playground-host 安装;不污染 JSON-RPC stdout(落 stderr + notify)。 - */ - -export type ModuleLogNotify = (payload: { - channel: "module" | "msg"; - source: string; - level: "info" | "warn" | "error" | "debug"; - text: string; -}) => void; - -export type ModuleLogBridgeHandle = { - /** 恢复 console 并卸 Msg 钩子 */ - dispose(): void; -}; - -const SKIP_PREFIXES = [ - "[playground]", - "[playground-host]", - "[objects]", - "[fixture]", - "[sfmc-testing]", -]; - -function shouldForward(text: string): boolean { - const t = text.trim(); - if (!t) return false; - return !SKIP_PREFIXES.some((p) => t.includes(p)); -} - -function formatArgs(args: unknown[]): string { - return args - .map((a) => { - if (typeof a === "string") return a; - if (a instanceof Error) return a.stack || a.message; - try { - return JSON.stringify(a); - } catch { - return String(a); - } - }) - .join(" "); -} - -/** - * 劫持 console.*:stderr 可见 + notify(source=moduleId)。 - * 跳过已由宿主 notify 的前缀,避免与 host log 双刷。 - */ -export function installModuleLogBridge( - moduleId: string, - notify: ModuleLogNotify, - opts?: { - /** 可选:注册 Msg 系统转发;返回卸钩函数 */ - registerMsg?: (handler: (playerName: string, text: string) => void) => () => void; - } -): ModuleLogBridgeHandle { - const source = moduleId.trim() || "module"; - const methods = ["log", "info", "warn", "error", "debug"] as const; - const originals: Record<(typeof methods)[number], (...args: unknown[]) => void> = { - log: console.log.bind(console), - info: console.info.bind(console), - warn: console.warn.bind(console), - error: console.error.bind(console), - debug: console.debug.bind(console), - }; - - for (const m of methods) { - console[m] = (...args: unknown[]) => { - const text = formatArgs(args); - try { - process.stderr.write(`${text}\n`); - } catch { - /* ignore */ - } - if (!shouldForward(text)) return; - const level = m === "log" || m === "info" ? "info" : m; - notify({ channel: "module", source, level, text }); - }; - } - - let unregMsg: (() => void) | undefined; - if (opts?.registerMsg) { - unregMsg = opts.registerMsg((playerName, text) => { - const line = `[Msg] ${playerName}: ${text}`; - notify({ channel: "msg", source, level: "info", text: line }); - }); - } - - return { - dispose() { - for (const m of methods) { - console[m] = originals[m] as typeof console.log; - } - unregMsg?.(); - }, - }; -} - -export function shouldForwardModuleLog(text: string): boolean { - return shouldForward(text); -} diff --git a/_sandbox_archive/testing/objects.ts b/_sandbox_archive/testing/objects.ts deleted file mode 100644 index cbd39b56..00000000 --- a/_sandbox_archive/testing/objects.ts +++ /dev/null @@ -1,332 +0,0 @@ -/** - * sb.objects — 1:1 构造 / 调用实例(元数据见 playground-meta) - * - * - 世界对象:Player / Entity / ItemStack / Block(引擎入口) - * - 事件对象:*AfterEvent / *BeforeEvent 属性袋(供 emit payload) - */ - -import { ItemStack } from "./engine/overrides/inventory.js"; -import type { FakePlayer, FakePlayerInit } from "./engine/overrides/player.js"; -import type { FakeWorld } from "./engine/overrides/world.js"; -import type { FakeSystem } from "./engine/overrides/system.js"; -import { PLAYGROUND_META } from "./engine/generated/playground-meta.js"; -import { UnimplementedMinecraftApiError } from "./engine/unimplemented-error.js"; -import { DEFAULT_DIMENSION_IDS } from "./engine/overrides/dimension.js"; - -export type SandboxObjectKind = string; - -export type SandboxObjectHandle = { - id: string; - kind: SandboxObjectKind; - target: unknown; -}; - -type Host = { - world: FakeWorld; - system: FakeSystem; - addPlayer: (init: FakePlayerInit) => FakePlayer; -}; - -let seq = 0; - -const ENGINE_KINDS = new Set(["Player", "Entity", "ItemStack", "Block"]); - -function serializeInspectValue(value: unknown): unknown { - if (value == null) return value; - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - return value; - } - if (typeof value === "object") { - const o = value as Record; - if (typeof o.id === "string" && typeof o.name === "string") { - return { $refHint: o.id, name: o.name }; - } - if (typeof o.id === "string" && typeof o.typeId === "string") { - return { $refHint: o.id, typeId: o.typeId }; - } - if (typeof o.x === "number" && typeof o.y === "number" && typeof o.z === "number") { - return { x: o.x, y: o.y, z: o.z }; - } - if (typeof o.id === "string") return { id: o.id }; - } - try { - return JSON.parse(JSON.stringify(value)); - } catch { - return String(value); - } -} - -export function createObjectRegistry(host: Host) { - const byId = new Map(); - - function register(kind: SandboxObjectKind, target: unknown, id?: string): SandboxObjectHandle { - const handle: SandboxObjectHandle = { - id: id ?? `${kind}-${++seq}`, - kind, - target, - }; - byId.set(handle.id, handle); - return handle; - } - - /** 解析 props 中的 `$ref:id` → 已登记实例 */ - function resolveRefs(value: unknown): unknown { - if (value && typeof value === "object" && !Array.isArray(value)) { - const o = value as Record; - if (typeof o.$ref === "string") { - const h = byId.get(o.$ref); - if (!h) throw new Error(`unknown $ref: ${o.$ref}`); - return h.target; - } - const out: Record = {}; - for (const [k, v] of Object.entries(o)) out[k] = resolveRefs(v); - return out; - } - if (Array.isArray(value)) return value.map(resolveRefs); - return value; - } - - function applyProps(kind: string, target: Record, props: Record) { - const meta = PLAYGROUND_META.classes[kind as keyof typeof PLAYGROUND_META.classes]; - const known: Set | null = meta - ? new Set(meta.properties.map((p) => p.name as string)) - : null; - for (const [k, v] of Object.entries(props)) { - if (k === "$ref") continue; - if (known && !known.has(k)) continue; - try { - target[k] = resolveRefs(v); - } catch (e) { - if (e instanceof Error && e.message.startsWith("unknown $ref")) throw e; - /* setter 拒绝则跳过 */ - } - } - } - - function createEventBag(kind: string, props: Record): SandboxObjectHandle { - const bag: Record = {}; - applyProps(kind, bag, props); - return register(kind, bag); - } - - // World / Dimension 沙箱天生登记;Scoreboard 底层单例仍在 world.scoreboard,须 create 后才进场景坞 - register("World", host.world, "world"); - for (const dimId of DEFAULT_DIMENSION_IDS) { - const dim = host.world.getDimension(dimId); - register("Dimension", dim, `dim:${dim.id}`); - } - - function findScoreboardHandle(): SandboxObjectHandle | undefined { - for (const h of byId.values()) { - if (h.kind === "Scoreboard" || h.target === host.world.scoreboard) return h; - } - return undefined; - } - - /** 将 world.scoreboard 登记为场景实例(单例;重复 create 返回已有句柄) */ - function materializeScoreboard(props: Record = {}): SandboxObjectHandle { - const existing = findScoreboardHandle(); - if (existing) return existing; - const preferredId = - typeof props.id === "string" && props.id.trim() ? props.id.trim() : "scoreboard"; - return register("Scoreboard", host.world.scoreboard, preferredId); - } - - return { - meta: PLAYGROUND_META, - get(id: string): SandboxObjectHandle | undefined { - return byId.get(id); - }, - list(): SandboxObjectHandle[] { - return [...byId.values()]; - }, - /** 可构造种类:引擎入口 + Scoreboard + 全部 Event 类型(不含 World/Dimension) */ - kinds(): string[] { - const out = new Set(["Player", "Entity", "ItemStack", "Block", "Scoreboard"]); - for (const [name, info] of Object.entries(PLAYGROUND_META.classes)) { - if ((info as { kind?: string }).kind === "event") out.add(name); - } - return [...out].sort(); - }, - /** 场景树用:World / Dimension 天生;Scoreboard / Player / Entity… 仅已登记实例 */ - sceneNodes(): { - world: { id: string; kind: string }; - scoreboard?: { id: string; kind: string }; - dimensions: { id: string; kind: string; dimensionId: string }[]; - players: { id: string; kind: string; name: string }[]; - entities: { id: string; kind: string; typeId?: string }[]; - items: { id: string; kind: string; typeId?: string }[]; - blocks: { id: string; kind: string }[]; - } { - const dims: { id: string; kind: string; dimensionId: string }[] = []; - const players: { id: string; kind: string; name: string }[] = []; - const entities: { id: string; kind: string; typeId?: string }[] = []; - const items: { id: string; kind: string; typeId?: string }[] = []; - const blocks: { id: string; kind: string }[] = []; - let scoreboard: { id: string; kind: string } | undefined; - for (const h of byId.values()) { - if (h.kind === "Scoreboard") { - scoreboard = { id: h.id, kind: h.kind }; - } else if (h.kind === "Dimension") { - const t = h.target as { id?: string }; - dims.push({ id: h.id, kind: h.kind, dimensionId: String(t.id ?? h.id) }); - } else if (h.kind === "Player") { - const t = h.target as { name?: string }; - players.push({ id: h.id, kind: h.kind, name: t.name ?? h.id }); - } else if (h.kind === "Entity") { - const t = h.target as { typeId?: string }; - const row: { id: string; kind: string; typeId?: string } = { id: h.id, kind: h.kind }; - if (typeof t.typeId === "string") row.typeId = t.typeId; - entities.push(row); - } else if (h.kind === "ItemStack") { - const t = h.target as { typeId?: string }; - const row: { id: string; kind: string; typeId?: string } = { id: h.id, kind: h.kind }; - if (typeof t.typeId === "string") row.typeId = t.typeId; - items.push(row); - } else if (h.kind === "Block") { - blocks.push({ id: h.id, kind: h.kind }); - } - } - return { - world: { id: "world", kind: "World" }, - ...(scoreboard ? { scoreboard } : {}), - dimensions: dims, - players, - entities, - items, - blocks, - }; - }, - inspect(id: string): { id: string; kind: string; props: Record } { - const h = byId.get(id); - if (!h) throw new Error(`unknown object id: ${id}`); - const classMeta = PLAYGROUND_META.classes[ - h.kind as keyof typeof PLAYGROUND_META.classes - ] as unknown as { properties?: ReadonlyArray<{ name: string }> } | undefined; - const target = h.target as Record; - const props: Record = {}; - for (const p of classMeta?.properties ?? []) { - try { - props[p.name] = serializeInspectValue(target[p.name]); - } catch { - props[p.name] = null; - } - } - return { id: h.id, kind: h.kind, props }; - }, - create(kind: SandboxObjectKind, props: Record = {}): SandboxObjectHandle { - if (kind === "World" || kind === "Dimension") { - throw new UnimplementedMinecraftApiError( - `objects.create(${kind}):沙箱天生已有,请从场景树选中` - ); - } - if (kind === "Scoreboard") { - return materializeScoreboard(props); - } - const classMeta = PLAYGROUND_META.classes[kind as keyof typeof PLAYGROUND_META.classes] as - | { kind?: string } - | undefined; - - if (classMeta?.kind === "event" || (!ENGINE_KINDS.has(kind) && kind.endsWith("Event"))) { - return createEventBag(kind, props); - } - - if (kind === "Player") { - const name = String(props.name ?? "player"); - const init: FakePlayerInit = { - name, - op: Boolean(props.op), - }; - if (props.id != null) init.id = String(props.id); - if (props.nameTag != null) init.nameTag = String(props.nameTag); - if (typeof props.playerPermissionLevel === "number") { - init.permissionLevel = props.playerPermissionLevel; - } else if (typeof props.permissionLevel === "number") { - init.permissionLevel = props.permissionLevel; - } - if (props.location) init.location = props.location as NonNullable; - if (typeof props.dimensionId === "string") { - init.dimensionId = props.dimensionId; - } else if (typeof (props.dimension as { id?: string } | undefined)?.id === "string") { - init.dimensionId = (props.dimension as { id: string }).id; - } - const player = host.addPlayer(init); - applyProps("Player", player as unknown as Record, props); - applyProps("Entity", player as unknown as Record, props); - return register("Player", player, player.id); - } - - if (kind === "ItemStack") { - const typeId = String(props.typeId ?? "minecraft:apple"); - const amount = typeof props.amount === "number" ? props.amount : 1; - const stack = new ItemStack(typeId, amount); - applyProps("ItemStack", stack as unknown as Record, props); - const preferredId = - typeof props.id === "string" && props.id.trim() ? props.id.trim() : undefined; - return register("ItemStack", stack, preferredId); - } - - if (kind === "Entity") { - const bag = resolveRefs({ ...props }) as Record; - const typeId = String(bag.typeId ?? "minecraft:cow"); - const loc = (bag.location as { x: number; y: number; z: number }) ?? { - x: 0, - y: 0, - z: 0, - }; - const dimId = - typeof bag.dimensionId === "string" - ? bag.dimensionId - : typeof (bag.dimension as { id?: string } | undefined)?.id === "string" - ? (bag.dimension as { id: string }).id - : "minecraft:overworld"; - const preferredId = - typeof bag.id === "string" && bag.id.trim() ? bag.id.trim() : undefined; - const entity = host.world - .getDimension(dimId) - .spawnEntity(typeId, loc, preferredId ? { id: preferredId } : undefined); - applyProps("Entity", entity as unknown as Record, props); - return register("Entity", entity, String((entity as { id: string }).id)); - } - - if (kind === "Block") { - const loc = (props.location as { x: number; y: number; z: number }) ?? { - x: 0, - y: 0, - z: 0, - }; - const dimId = - typeof props.dimensionId === "string" ? props.dimensionId : "minecraft:overworld"; - const dim = host.world.getDimension(dimId); - const typeId = String(props.typeId ?? "minecraft:stone"); - dim.setBlockType(loc, typeId); - const block = dim.getBlock(loc); - applyProps("Block", block as unknown as Record, props); - return register("Block", block, `block:${dimId}:${loc.x},${loc.y},${loc.z}`); - } - - // 元数据中有、但非引擎入口:按属性袋构造(供嵌套类型等) - if (classMeta) { - return createEventBag(kind, props); - } - - throw new UnimplementedMinecraftApiError(`objects.create(${kind})`); - }, - call(id: string, method: string, args: unknown[] = []): unknown { - const handle = byId.get(id); - if (!handle) throw new Error(`unknown object id: ${id}`); - const target = handle.target as Record; - const fn = target[method]; - if (typeof fn !== "function") { - throw new UnimplementedMinecraftApiError(`${handle.kind}.${method}`); - } - return (fn as (...a: unknown[]) => unknown).apply(target, args.map(resolveRefs)); - }, - /** 取出实例供 emit(解析 $ref) */ - resolve(idOrRef: unknown): unknown { - return resolveRefs(idOrRef); - }, - }; -} - -export type SandboxObjects = ReturnType; diff --git a/_sandbox_archive/testing/playground-host.ts b/_sandbox_archive/testing/playground-host.ts deleted file mode 100644 index c2e84d0c..00000000 --- a/_sandbox_archive/testing/playground-host.ts +++ /dev/null @@ -1,702 +0,0 @@ -/** - * Playground JSON-RPC 宿主(stdio / TCP 行协议)。 - * 默认启动:node --import @sfmc-bds/sdk/testing/minecraft-loader --import tsx/esm <本文件> - * TCP 启动:追加 --transport=port:(port 可为 0 取随机端口,监听后把实际端口 - * 以 {"type":"event","name":"listen","payload":{"port":N}} 写到 stdout)。 - * 扩展「启动并调试」用该传输,使面板与调试宿主共用同一 sandbox 会话。 - * - * 请求:meta / start(=重置) / stop / objects.* / events.* / tick / scene.summary / - * fixture.get / fixture.apply / smoke.run - * 通知:{"type":"event","name":"progress"|"log","payload":...} - * UI 开面板即 start;无启动/销毁主按钮。 - * - * 环境变量 SFMC_PLAYGROUND_MODULE_ROOT:默认模块根(可被 start.params.moduleRoot 覆盖)。 - * 夹具意图在 start/reset 间保留,并随 createSandbox({ fixture }) 重新注入。 - */ - -import fs from "node:fs"; -import net from "node:net"; -import path from "node:path"; -import readline from "node:readline"; -import { Command, Permission, registerSystemMsgHandler } from "@sfmc-bds/sdk/sapi/runtime"; -import { ModuleRegistry } from "@sfmc-bds/sdk/module-loader"; -import { createSandbox, type Sandbox } from "./sandbox.js"; -import { PLAYGROUND_META } from "./engine/generated/playground-meta.js"; -import { - buildFixtureSnapshot, - type SandboxFixtureIntent, -} from "./fixture.js"; -import { loadModuleDescriptor } from "./load-module.js"; -import { installModuleLogBridge, type ModuleLogBridgeHandle } from "./module-log-bridge.js"; - -type RpcReq = { id: number | string; method: string; params?: Record }; - -let sb: Sandbox | null = null; -let moduleLogBridge: ModuleLogBridgeHandle | null = null; -type LastEmitSnap = { - path: string; - at: number; - /** 调用方原始 payload(保留 $ref) */ - payload: unknown; - /** emit 后事件对象摘要(含 cancel 等) */ - result: unknown; - /** 同步调用的订阅回调数 */ - listeners: number; - /** 订阅回调同步抛错摘要 */ - errors: { message: string }[]; -}; -type LastCallSnap = { - id: string; - method: string; - result: unknown; - at: number; -}; -let lastEmit: LastEmitSnap | null = null; -let lastCall: LastCallSnap | null = null; -let activeModuleRoot: string | null = null; -/** 重置场景后保留的夹具意图。 */ -let fixtureIntent: SandboxFixtureIntent = {}; - -function parseFixtureIntent(raw: unknown): SandboxFixtureIntent { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; - const o = raw as Record; - const intent: SandboxFixtureIntent = {}; - if (o.settings && typeof o.settings === "object" && !Array.isArray(o.settings)) { - intent.settings = o.settings as Record; - } - if (Array.isArray(o.permissions)) { - intent.permissions = o.permissions - .filter((p): p is { player_name: string; level: number } => { - if (!p || typeof p !== "object") return false; - const row = p as Record; - return typeof row.player_name === "string" && typeof row.level === "number"; - }) - .map((p) => ({ player_name: p.player_name, level: p.level })); - } - if (typeof o.treatPlayersAsOp === "boolean") intent.treatPlayersAsOp = o.treatPlayersAsOp; - if (typeof o.enabled === "boolean") intent.enabled = o.enabled; - if (typeof o.clearDb === "boolean") intent.clearDb = o.clearDb; - return intent; -} - -function mergeFixtureIntent(patch: SandboxFixtureIntent): SandboxFixtureIntent { - const next: SandboxFixtureIntent = { ...fixtureIntent }; - if (patch.settings !== undefined) next.settings = structuredClone(patch.settings); - if (patch.permissions !== undefined) next.permissions = structuredClone(patch.permissions); - if (patch.treatPlayersAsOp !== undefined) next.treatPlayersAsOp = patch.treatPlayersAsOp; - if (patch.enabled !== undefined) next.enabled = patch.enabled; - if (patch.clearDb !== undefined) next.clearDb = patch.clearDb; - fixtureIntent = next; - return next; -} - -function fixtureSnapshot() { - if (!sb) throw new Error("sandbox not started"); - const id = sb.module?.id ?? null; - return buildFixtureSnapshot({ - module: sb.module, - moduleRoot: activeModuleRoot, - enabled: id ? ModuleRegistry.isActive(id) : null, - adapter: sb.configAdapter, - intent: fixtureIntent, - db: sb.db, - }); -} - -/** 规范化为可 JSON 断言的摘要(深度有限) */ -function snapshotValue(value: unknown, depth = 0): unknown { - if (depth > 4) return "[…]"; - if (value == null) return value; - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - return value; - } - if (typeof value === "function") return "[Function]"; - if (Array.isArray(value)) { - return value.slice(0, 32).map((v) => snapshotValue(v, depth + 1)); - } - if (typeof value === "object") { - const o = value as Record; - if (typeof o.$ref === "string") return { $ref: o.$ref }; - if (typeof o.id === "string" && typeof o.name === "string") { - return { $refHint: o.id, name: o.name }; - } - if (typeof o.id === "string" && typeof o.typeId === "string") { - return { $refHint: o.id, typeId: o.typeId }; - } - if (typeof o.x === "number" && typeof o.y === "number" && typeof o.z === "number") { - return { x: o.x, y: o.y, z: o.z }; - } - const out: Record = {}; - let n = 0; - for (const [k, v] of Object.entries(o)) { - if (n++ >= 40) { - out["…"] = "truncated"; - break; - } - try { - out[k] = snapshotValue(v, depth + 1); - } catch { - out[k] = String(v); - } - } - return out; - } - return String(value); -} - -/** 可写一行 JSON 的会话(stdio stdout 或单个 TCP socket)。 */ -type Session = { - write: (line: string) => void; - close: () => void; -}; - -/** 活跃会话;notify(事件)广播到全部会话。 */ -const sessions = new Set(); - -function notify(name: string, payload: unknown): void { - const line = JSON.stringify({ type: "event", name, payload }); - for (const s of sessions) s.write(line); -} - -function disposeModuleLogBridge(): void { - moduleLogBridge?.dispose(); - moduleLogBridge = null; - // 卸 Msg 转发 - registerSystemMsgHandler(() => {}); -} - -function attachModuleLogBridge(moduleId: string | null): void { - disposeModuleLogBridge(); - if (!moduleId) return; - moduleLogBridge = installModuleLogBridge(moduleId, (payload) => { - notify("log", payload); - }, { - registerMsg(handler) { - registerSystemMsgHandler((player, text) => { - const name = - player && typeof player === "object" && "name" in player - ? String((player as { name?: unknown }).name ?? "?") - : "?"; - handler(name, text); - }); - return () => registerSystemMsgHandler(() => {}); - }, - }); -} - -function reply(session: Session, id: number | string, result: unknown): void { - session.write(JSON.stringify({ id, result })); -} - -function replyError(session: Session, id: number | string, message: string): void { - session.write(JSON.stringify({ id, error: { message } })); -} - -function resolveModuleRoot(params: Record): string | undefined { - const fromParams = typeof params.moduleRoot === "string" ? params.moduleRoot.trim() : ""; - if (fromParams) return fromParams; - const fromEnv = (process.env.SFMC_PLAYGROUND_MODULE_ROOT || "").trim(); - return fromEnv || undefined; -} - -/** 读模块 package.json version(DESCRIPTOR 无 version 字段)。 */ -function readPackageVersion(moduleRoot: string): string | undefined { - try { - const raw = fs.readFileSync(path.join(moduleRoot, "package.json"), "utf8"); - const pkg = JSON.parse(raw) as { version?: unknown }; - return typeof pkg.version === "string" && pkg.version.trim() ? pkg.version.trim() : undefined; - } catch { - return undefined; - } -} - -/** 按模块过滤已注册命令;无 moduleId 标注的在单模块沙箱一并计入。 */ -function listCommandsForModule(moduleId: string | null) { - return Command.entries().filter((e) => { - if (e.name === "help" || e.name === "permlist") return false; - if (!moduleId) return true; - return e.moduleId === undefined || e.moduleId === moduleId; - }); -} - -/** boot 后模块绑定摘要(供 Webview / Output 对账「装的就是我的模块」)。 */ -function buildModuleBinding(sb: Sandbox, moduleRoot: string | null) { - const subscribed = sb.events.subscribedPaths(); - const id = sb.module?.id ?? null; - const version = moduleRoot ? readPackageVersion(moduleRoot) : undefined; - const enabled = id ? ModuleRegistry.isActive(id) : null; - const desc = id ? ModuleRegistry.get(id) : undefined; - const bootPhase = ModuleRegistry.getBootPhase(); - const commands = listCommandsForModule(id); - const permissions = Permission.entries(); - return { - moduleRoot, - id, - version: version ?? null, - enabled, - afterWorldLoad: desc?.afterWorldLoad ?? null, - status: id ? ("loaded" as const) : ("engine-only" as const), - subscribedEvents: subscribed, - eventNote: id - ? "事件由模块 registerEvents 注册(含宿主 chat→命令桥等)" - : "engine only:无模块 registerEvents", - commands: { - enumerable: true as const, - items: commands, - }, - permissions: { - enumerable: true as const, - items: permissions, - note: "命名权限无模块归属;列出进程内 Permission.register 全表", - }, - bootPhase, - /* v3 manifest 摘要(含 semantic)——沙箱读模块语义镜像的入口。 */ - moduleManifest: sb.moduleManifest - ? { - schemaVersion: sb.moduleManifest.schemaVersion, - id: sb.moduleManifest.id, - name: sb.moduleManifest.name, - type: sb.moduleManifest.type, - configKey: sb.moduleManifest.configKey, - requires: sb.moduleManifest.requires, - permissions: sb.moduleManifest.permissions, - services: sb.moduleManifest.services, - ...(sb.moduleManifest.notes !== undefined ? { notes: sb.moduleManifest.notes } : {}), - semantic: sb.moduleManifest.semantic, - } - : null, - }; -} - -async function flushCommandSideEffects(): Promise { - if (!sb) return; - sb.flush(); - await Promise.resolve(); - sb.flush(); - await Promise.resolve(); - sb.flush(); -} - -/** 对已注册命令走 !name + chatSend(禁止扩展直接 triggerCommand 作手点主路径)。 */ -async function runSmoke(moduleId: string | null): Promise<{ - ok: boolean; - module: string | null; - commands: string[]; - results: { name: string; ok: boolean; log: string[] }[]; -}> { - if (!sb) throw new Error("sandbox not started"); - const player = sb.addPlayer({ name: "smoke", op: true }); - const names = Command.names().filter((n) => { - if (n === "help") return false; - if (!moduleId) return true; - const mid = Command.getModuleId(n); - // 未标注 moduleId 的命令在单模块沙箱里一并冒烟 - return mid === undefined || mid === moduleId; - }); - const results: { name: string; ok: boolean; log: string[] }[] = []; - for (const name of names) { - const before = player.log.length; - sb.emit.chatSend(player, `!${name}`); - await flushCommandSideEffects(); - const log = player.log.slice(before); - const failed = log.some((line) => line.includes("§c[x]") || line.includes("未知的命令")); - results.push({ name, ok: !failed, log }); - notify("log", { - channel: "system", - text: `[smoke] !${name} → ${failed ? "FAIL" : "ok"}`, - }); - } - return { - ok: results.every((r) => r.ok), - module: moduleId, - commands: names, - results, - }; -} - -async function handle(req: RpcReq): Promise { - const { method, params = {} } = req; - switch (method) { - case "meta": - return PLAYGROUND_META; - case "ping": - return { ok: true }; - case "start": { - if (sb) await sb.dispose(); - disposeModuleLogBridge(); - lastEmit = null; - lastCall = null; - const moduleRoot = resolveModuleRoot(params); - activeModuleRoot = moduleRoot ?? null; - // start.params.fixture 可覆盖保留意图;缺省沿用上次 fixtureIntent - if (params.fixture !== undefined) { - mergeFixtureIntent(parseFixtureIntent(params.fixture)); - } - if (params.clearFixture === true) { - fixtureIntent = {}; - } - const hasFixture = - fixtureIntent.settings !== undefined || - fixtureIntent.permissions !== undefined || - fixtureIntent.treatPlayersAsOp !== undefined || - fixtureIntent.enabled !== undefined || - fixtureIntent.clearDb === true; - // boot 前劫持 console,避免污染 JSON-RPC stdout,并以模块 id 为 source - let peekId: string | null = null; - if (moduleRoot) { - try { - peekId = (await loadModuleDescriptor(moduleRoot)).id; - } catch { - peekId = null; - } - } - attachModuleLogBridge(peekId); - try { - sb = await createSandbox({ - ...(moduleRoot ? { moduleRoot } : {}), - ...(hasFixture ? { fixture: { ...fixtureIntent, clearDb: false } } : {}), - onProgress(step) { - notify("progress", { phase: "start", ...step }); - }, - }); - } catch (e) { - disposeModuleLogBridge(); - throw e; - } - if (fixtureIntent.treatPlayersAsOp || fixtureIntent.clearDb) { - const post: SandboxFixtureIntent = {}; - if (fixtureIntent.treatPlayersAsOp) post.treatPlayersAsOp = true; - if (fixtureIntent.clearDb) post.clearDb = true; - await sb.applyFixture(post); - } - const binding = buildModuleBinding(sb, moduleRoot ?? null); - if (binding.id && binding.id !== peekId) { - attachModuleLogBridge(binding.id); - } - const modLabel = binding.id ?? "(engine only)"; - const subSummary = - binding.subscribedEvents.length > 0 - ? binding.subscribedEvents.map((e) => `${e.path}×${e.listeners}`).join(", ") - : "(无已订阅 path)"; - notify("log", { - channel: "system", - text: moduleRoot - ? `[playground] sandbox started module=${modLabel} version=${binding.version ?? "?"} enabled=${binding.enabled} root=${moduleRoot}` - : "[playground] sandbox started (engine only)", - }); - notify("log", { - channel: "system", - text: `[playground] ${binding.eventNote};subscribed=[${subSummary}]`, - }); - notify("log", { - channel: "system", - text: `[playground] inventory commands=${binding.commands.items.length} permissions=${binding.permissions.items.length} boot=${binding.bootPhase.summary}`, - }); - return { - ok: true, - module: sb.module - ? { - id: sb.module.id, - root: sb.module.root ?? moduleRoot ?? undefined, - version: binding.version, - enabled: binding.enabled, - afterWorldLoad: binding.afterWorldLoad, - } - : null, - moduleRoot: binding.moduleRoot, - moduleBinding: binding, - subscribedEvents: binding.subscribedEvents, - objectKinds: sb.objects.kinds(), - eventPathCount: sb.events.paths().length, - fixture: fixtureSnapshot(), - }; - } - case "stop": { - if (sb) { - notify("progress", { - phase: "stop", - id: 1, - layer: "native", - label: "system.beforeEvents.shutdown", - status: "running", - }); - await sb.dispose(); - sb = null; - lastEmit = null; - lastCall = null; - activeModuleRoot = null; - disposeModuleLogBridge(); - notify("progress", { - phase: "stop", - id: 1, - layer: "native", - label: "system.beforeEvents.shutdown", - status: "done", - }); - notify("log", { channel: "system", text: "[playground] sandbox disposed" }); - } - return { ok: true }; - } - case "objects.list": { - if (!sb) throw new Error("sandbox not started"); - return sb.objects.list().map((h) => ({ id: h.id, kind: h.kind })); - } - case "objects.inspect": { - if (!sb) throw new Error("sandbox not started"); - return sb.objects.inspect(String(params.id ?? "")); - } - case "objects.create": { - if (!sb) throw new Error("sandbox not started"); - const kind = String(params.kind ?? ""); - const props = (params.props as Record) ?? {}; - const h = sb.objects.create(kind, props); - notify("log", { channel: "system", text: `[objects] create ${h.kind} ${h.id}` }); - return { id: h.id, kind: h.kind }; - } - case "objects.call": { - if (!sb) throw new Error("sandbox not started"); - const id = String(params.id ?? ""); - const methodName = String(params.method ?? ""); - const args = Array.isArray(params.args) ? params.args : []; - const result = sb.objects.call(id, methodName, args); - const resultSnap = snapshotValue(result); - lastCall = { id, method: methodName, result: resultSnap, at: Date.now() }; - notify("log", { - channel: "system", - text: `[objects] call ${id}.${methodName}(${args.length} args)`, - }); - return { result: resultSnap }; - } - case "events.paths": { - if (!sb) throw new Error("sandbox not started"); - return sb.events.paths(); - } - case "events.subscribed": { - if (!sb) throw new Error("sandbox not started"); - return sb.events.subscribedPaths(); - } - case "events.emit": { - if (!sb) throw new Error("sandbox not started"); - const path = String(params.path ?? ""); - const payload = params.payload ?? {}; - const eventObj = sb.events.emit(path, payload); - const meta = sb.events.lastMeta(); - const listeners = meta?.listeners ?? 0; - const errors = (meta?.errors ?? []).map((e) => ({ message: e.message })); - lastEmit = { - path, - at: Date.now(), - payload: snapshotValue(payload), - result: snapshotValue(eventObj), - listeners, - errors, - }; - notify("log", { - channel: "system", - text: `[events] emit ${path} → ${listeners} listener(s)`, - }); - for (const err of errors) { - notify("log", { - channel: "system", - text: `[events] listener error: ${err.message}`, - }); - } - return { - ok: true, - path, - payload: lastEmit.payload, - result: lastEmit.result, - listeners, - errors, - }; - } - case "events.listenerCount": { - if (!sb) throw new Error("sandbox not started"); - const path = String(params.path ?? ""); - return { path, listeners: sb.events.listenerCount(path) }; - } - case "events.eventType": { - if (!sb) throw new Error("sandbox not started"); - return { eventType: sb.events.eventType(String(params.path ?? "")) ?? null }; - } - case "scene.summary": { - if (!sb) throw new Error("sandbox not started"); - const scene = sb.objects.sceneNodes(); - const binding = buildModuleBinding(sb, activeModuleRoot); - return { - started: true, - ...scene, - playerCount: scene.players.length, - objectCount: sb.objects.list().length, - eventPathCount: sb.events.paths().length, - lastEmit, - lastCall, - module: sb.module?.id ?? null, - moduleRoot: activeModuleRoot, - moduleBinding: binding, - subscribedEvents: binding.subscribedEvents, - note: sb.module ? `module=${sb.module.id}` : "engine only", - }; - } - case "tick": { - if (!sb) throw new Error("sandbox not started"); - const n = typeof params.n === "number" ? params.n : 1; - sb.tick(n); - return { ok: true, n }; - } - case "smoke.run": { - return runSmoke(sb?.module?.id ?? null); - } - case "fixture.get": { - return fixtureSnapshot(); - } - case "fixture.apply": { - if (!sb) throw new Error("sandbox not started"); - const patch = parseFixtureIntent(params.fixture ?? params); - const merged = mergeFixtureIntent(patch); - await sb.applyFixture(merged); - notify("log", { - channel: "system", - text: `[fixture] applied settings=${Object.keys(merged.settings ?? {}).length} perms=${(merged.permissions ?? []).length} op=${!!merged.treatPlayersAsOp} enabled=${merged.enabled ?? "—"} clearDb=${!!merged.clearDb}`, - }); - return fixtureSnapshot(); - } - case "fixture.clearDb": { - if (!sb) throw new Error("sandbox not started"); - sb.clearDb(); - notify("log", { channel: "system", text: "[fixture] fake-db call log cleared" }); - return fixtureSnapshot(); - } - default: - throw new Error(`unknown method: ${method}`); - } -} - -/** 逐行处理单条请求,答复回同一会话;错误转 error 通知。 */ -async function handleLine(session: Session, line: string): Promise { - const trimmed = line.trim(); - if (!trimmed) return; - let req: RpcReq; - try { - req = JSON.parse(trimmed) as RpcReq; - } catch { - notify("log", { channel: "system", text: `[playground-host] bad json: ${trimmed.slice(0, 80)}` }); - return; - } - if (req.id === undefined || !req.method) return; - try { - const result = await handle(req); - reply(session, req.id, result); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - replyError(session, req.id, message); - notify("log", { channel: "system", text: `[error] ${message}` }); - } -} - -/** 每会话串行处理请求,保持与原 for-await 一致的顺序语义。 */ -function createLineProcessor(session: Session): (line: string) => void { - let tail = Promise.resolve(); - return (line: string) => { - tail = tail - .then(() => handleLine(session, line)) - .catch((e) => { - notify("log", { - channel: "system", - text: `[playground-host] fatal: ${e instanceof Error ? e.message : String(e)}`, - }); - }); - }; -} - -/** 优雅退出:dispose sandbox 后结束进程(调试器 stop 走 SIGTERM)。 */ -function onShutdown(): void { - void (async () => { - try { - if (sb) await sb.dispose(); - } finally { - process.exit(0); - } - })(); -} - -function runStdio(): void { - const stdoutSession: Session = { - write(line) { - process.stdout.write(`${line}\n`); - }, - close() { - /* stdout 无独立关闭语义 */ - }, - }; - sessions.add(stdoutSession); - notify("log", { channel: "system", text: "[playground-host] ready" }); - const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); - rl.on("line", createLineProcessor(stdoutSession)); - rl.on("close", () => { - if (sb) void sb.dispose().catch(() => undefined); - }); -} - -function runTcp(port: number): void { - const server = net.createServer((socket) => { - const session: Session = { - write(line) { - socket.write(`${line}\n`); - }, - close() { - socket.destroy(); - }, - }; - sessions.add(session); - const rl = readline.createInterface({ input: socket, crlfDelay: Infinity }); - rl.on("line", createLineProcessor(session)); - socket.on("close", () => { - sessions.delete(session); - rl.close(); - }); - socket.on("error", () => { - /* close 已兜底清理 */ - }); - }); - server.on("error", (e) => { - console.error(e); - process.exit(1); - }); - server.listen(port, "127.0.0.1", () => { - const addr = server.address() as net.AddressInfo; - // 扩展侧用预选端口连接,此行为调试终端 / socket 测试提供实际端口 - process.stdout.write( - `${JSON.stringify({ type: "event", name: "listen", payload: { port: addr.port } })}\n` - ); - notify("log", { channel: "system", text: `[playground-host] ready (tcp :${addr.port})` }); - }); -} - -function parseTransportArg(): { kind: "stdio" } | { kind: "tcp"; port: number } { - const arg = process.argv.find((a) => a.startsWith("--transport=")); - if (!arg) return { kind: "stdio" }; - const value = arg.slice("--transport=".length); - if (value === "stdio") return { kind: "stdio" }; - const m = /^port:(\d+)$/.exec(value); - if (m) return { kind: "tcp", port: Number(m[1]) }; - throw new Error(`unknown --transport: ${value}`); -} - -async function main(): Promise { - const transport = parseTransportArg(); - if (transport.kind === "tcp") { - runTcp(transport.port); - } else { - runStdio(); - } -} - -for (const sig of ["SIGINT", "SIGTERM"] as const) { - process.on(sig, onShutdown); -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/_sandbox_archive/testing/sandbox.ts b/_sandbox_archive/testing/sandbox.ts deleted file mode 100644 index 1ef5ec5a..00000000 --- a/_sandbox_archive/testing/sandbox.ts +++ /dev/null @@ -1,589 +0,0 @@ -/** - * createSandbox — 对齐 BDS 宿主分相:ConfigManager + ModuleRegistry + 假 worldLoad。 - */ - -import type { ManifestV3, ModuleDescriptor } from "@sfmc-bds/sdk/module-loader"; -import { ConfigManager, ModuleRegistry } from "@sfmc-bds/sdk/module-loader"; -import { Command, Permission } from "@sfmc-bds/sdk/sapi/runtime"; -import { - disposeEngine, - resetEngine, - createEnginePlayer, - getSystem, - type FakePlayer, - type FakePlayerInit, - type FakeSystem, - type FakeWorld, - type FakeUiHost, -} from "./engine/runtime.js"; -import { createFakeDb, type FakeDb, type FakeDbStub } from "./fake-db.js"; -import { runCleanup } from "./lifecycle.js"; -import { - createMemoryDataAdapter, - type MemoryConfigsAll, - type MemoryDataAdapter, -} from "./host/memory-data-adapter.js"; -import { SERVER_L0_META, SERVER_UI_L0_META } from "./engine/generated/l0-meta.js"; -import { createObjectRegistry, type SandboxObjects } from "./objects.js"; -import { createEventsDrive, type SandboxEvents } from "./events-drive.js"; -import { loadModuleDescriptor } from "./load-module.js"; -import { - applyFixtureIntent, - configsFromFixtureIntent, - resolveModuleManifest, - type SandboxFixtureIntent, -} from "./fixture.js"; -import { toDamageSource } from "./engine/overrides/damage-source.js"; -import type { FakeBlock, FakeBlockPermutation } from "./engine/overrides/dimension.js"; - -export type { MemoryConfigsAll, MemoryDataAdapter }; -export type { SandboxFixtureIntent }; - -/** 宿主分相进度(playground-host / UI 勾选)。 */ -export type SandboxProgressStep = { - id: number; - layer: "native" | "sfmc" | "ready"; - label: string; - status: "running" | "done" | "error"; -}; - -export interface CreateSandboxOpts { - /** 模块描述符;缺省则只起假引擎、不 boot。 */ - module?: ModuleDescriptor; - /** - * 模块根目录:装载 sapi/src/index.ts(或 .js/.mjs),取 DESCRIPTOR 后 boot。 - * 与 module 同时给出时以 moduleRoot 为准。 - */ - moduleRoot?: string; - /** 默认 true:走 Registry boot + 假 worldLoad。 */ - boot?: boolean; - /** 覆盖内存 configs/all(缺省为当前 module 启用)。 */ - configs?: MemoryConfigsAll; - /** 模块是否启用;默认 true(写入 configs.modules)。 */ - enabled?: boolean; - /** - * 夹具意图:合并进 configs 后 boot;与 configs 同时给出时先 configs 再叠 intent。 - * 运行中改夹具请用 sandbox.applyFixture / playground-host fixture.apply。 - */ - fixture?: SandboxFixtureIntent; - db?: FakeDbStub; - /** 分相进度回调(原生层 / SFMC 层分标)。 */ - onProgress?: (step: SandboxProgressStep) => void; -} - -/** 沙箱侧事件触发(非 MC 公开 API)。 */ -export type SandboxEmit = { - playerJoin(player: FakePlayer): void; - playerSpawn(player: FakePlayer, opts?: { initialSpawn?: boolean }): void; - /** 默认走 beforeEvents.chatSend;`after: true` 时再发 afterEvents.chatSend。 */ - chatSend(player: FakePlayer, message: string, opts?: { after?: boolean }): void; - scriptEvent(id: string, sourceEntity?: unknown): void; - /** 从世界移除玩家并触发 playerLeave(before + after)。 */ - playerLeave(player: FakePlayer): void; - /** itemUse:默认 after;`before: true` 时走 beforeEvents(可带 cancel)。 */ - itemUse( - player: FakePlayer, - itemStack?: unknown, - opts?: { before?: boolean; itemStack?: unknown } - ): void; - /** playerBreakBlock:默认 after;payload 对齐 Event 属性袋(可缺省)。 */ - playerBreakBlock( - player: FakePlayer, - opts?: { - before?: boolean; - block?: unknown; - brokenBlockPermutation?: unknown; - itemStackBeforeBreak?: unknown; - itemStackAfterBreak?: unknown; - } - ): void; - /** - * playerPlaceBlock:默认 before→(未 cancel 则落块)→after; - * `before: true` 仅 before(area/creative cancel 主路径)。 - */ - playerPlaceBlock( - player: FakePlayer, - opts?: { - before?: boolean; - block?: FakeBlock; - permutationToPlace?: FakeBlockPermutation; - face?: string; - faceLocation?: { x: number; y: number; z: number }; - } - ): void; - /** playerInteractWithBlock:默认 after;`before: true` 可带 cancel。 */ - playerInteractWithBlock( - player: FakePlayer, - opts?: { - before?: boolean; - block?: FakeBlock; - blockFace?: string; - faceLocation?: { x: number; y: number; z: number }; - itemStack?: unknown; - beforeItemStack?: unknown; - isFirstEvent?: boolean; - } - ): void; - /** - * playerInteractWithEntity:默认 after(activity-log 等); - * `before: true` 可带 cancel。payload 为薄事件袋。 - */ - playerInteractWithEntity( - player: FakePlayer, - target: unknown, - opts?: { - before?: boolean; - itemStack?: unknown; - beforeItemStack?: unknown; - } - ): void; - entityHitEntity(damagingEntity: unknown, hitEntity: unknown): void; -}; - -export type Sandbox = { - world: FakeWorld; - system: FakeSystem; - ui: FakeUiHost; - db: FakeDb; - /** 当前 boot 的模块;engine-only 时为 null。 */ - module: { id: string; root?: string } | null; - /** 解析后的 v3 模块 manifest(含 fixture.semantic 合并)。engine-only 或缺 manifest.json 时为 null。 */ - moduleManifest: ManifestV3 | null; - supported: { - events: string[]; - system: string[]; - ui: string[]; - /** L0 生成元数据(来自 gen-mc-fake)。 */ - l0: { - server: typeof SERVER_L0_META; - serverUi: typeof SERVER_UI_L0_META; - }; - }; - addPlayer(init: FakePlayerInit): FakePlayer; - /** L2:按真实事件名触发订阅(模块 registerEvents 后可断言)。 */ - emit: SandboxEmit; - tick(n?: number): void; - flush(): void; - /** 触发 ! 命令(走 Command.trigger;需先 boot 注册)。手点主路径请用 emit.chatSend。 */ - triggerCommand(name: string, player?: FakePlayer): Promise; - /** 1:1 构造 / 调用 */ - objects: SandboxObjects; - /** 1:1 事件 emit */ - events: SandboxEvents; - /** 内存 configs 适配器(夹具读写)。 */ - configAdapter: MemoryDataAdapter; - /** 运行中应用夹具意图并刷新 ConfigManager。 */ - applyFixture(intent: SandboxFixtureIntent): Promise; - /** 清空假 DB 调用日志。 */ - clearDb(): void; - dispose(): Promise; -}; - -const SUPPORTED = { - events: [ - "world.afterEvents.worldLoad", - "world.afterEvents.playerSpawn", - "world.afterEvents.playerJoin", - "world.afterEvents.chatSend", - "world.beforeEvents.chatSend", - "system.afterEvents.scriptEventReceive", - ], - system: ["run", "runTimeout", "runInterval", "clearRun", "waitTicks", "tick"], - ui: [ - "ActionFormData", - "MessageFormData", - "ModalFormData", - "CustomForm", - "MessageBox", - "uiManager", - "queueResponse", - ], - l0: { - server: SERVER_L0_META, - serverUi: SERVER_UI_L0_META, - }, -}; - -function defaultConfigsFor(module: ModuleDescriptor | undefined, enabled: boolean): MemoryConfigsAll { - if (!module) { - return { modules: [], settings: {}, permissions: [], module_tokens: {} }; - } - const id = module.id; - const configKey = id.includes("-") ? id.slice(id.indexOf("-") + 1).replace(/-/g, "_") : id; - return { - modules: [ - { - id, - configKey, - enabled, - installed: true, - }, - ], - module_tokens: { [id]: `test-token-${id}` }, - settings: {}, - permissions: [], - }; -} - -function progress( - onProgress: CreateSandboxOpts["onProgress"], - step: Omit, - status: SandboxProgressStep["status"] -): void { - onProgress?.({ ...step, status }); -} - -/** 聊天 `!` / `!` → Command.trigger(对齐 BDS 宿主拦截)。 */ -function installChatCommandBridge(world: FakeWorld): void { - world.beforeEvents.chatSend!.subscribe((ev) => { - const bag = ev as { sender?: FakePlayer; message?: string; cancel?: boolean }; - const raw = String(bag?.message ?? ""); - if (!raw.startsWith("!") && !raw.startsWith("!")) return; - const name = raw.slice(1).trim().split(/\s+/)[0]; - if (!name) return; - if (!bag.sender) return; - bag.cancel = true; - Command.trigger(bag.sender as never, name); - }); -} - -export async function createSandbox(opts: CreateSandboxOpts = {}): Promise { - const onProgress = opts.onProgress; - const report = (id: number, layer: SandboxProgressStep["layer"], label: string) => ({ - id, - layer, - label, - }); - - progress(onProgress, report(0, "native", "装载脚本入口(early)"), "running"); - ConfigManager.resetForTesting(); - ModuleRegistry.resetForTesting(); - - progress(onProgress, report(1, "native", "加载 System"), "running"); - const eng = resetEngine(); - const db = createFakeDb(opts.db); - progress(onProgress, report(1, "native", "加载 System"), "done"); - - progress(onProgress, report(2, "native", "加载 World 壳"), "running"); - progress(onProgress, report(2, "native", "加载 World 壳"), "done"); - - progress(onProgress, report(3, "native", "绑定 @minecraft 表面"), "running"); - globalThis.__sfmcBdsSystem = { - clearRun(id: number) { - getSystem().clearRun(id); - }, - runInterval(cb: () => void, ticks?: number) { - return getSystem().runInterval(cb, ticks); - }, - run(cb: () => void, ticks?: number) { - return getSystem().run(cb, ticks); - }, - }; - installChatCommandBridge(eng.world); - progress(onProgress, report(3, "native", "绑定 @minecraft 表面"), "done"); - progress(onProgress, report(0, "native", "装载脚本入口(early)"), "done"); - - const enabled = - opts.fixture?.enabled !== undefined ? opts.fixture.enabled !== false : opts.enabled !== false; - - // 先解析模块描述符(moduleRoot 优先),再写 configs - let moduleRef: ModuleDescriptor | undefined = opts.module; - const moduleRoot = opts.moduleRoot?.trim() || undefined; - if (moduleRoot) { - progress(onProgress, report(0, "native", "装载脚本入口(early)"), "running"); - moduleRef = await loadModuleDescriptor(moduleRoot); - progress(onProgress, report(0, "native", "装载脚本入口(early)"), "done"); - } - - const baseConfigs = opts.configs ?? defaultConfigsFor(moduleRef, enabled); - const configs = configsFromFixtureIntent(baseConfigs, opts.fixture, moduleRef?.id); - - progress(onProgress, report(4, "native", "system.beforeEvents.startup"), "running"); - progress(onProgress, report(5, "sfmc", "ConfigManager.init"), "running"); - const configAdapter = createMemoryDataAdapter(configs); - ConfigManager.bindDataAdapter(configAdapter); - await ConfigManager.init(); - progress(onProgress, report(5, "sfmc", "ConfigManager.init"), "done"); - progress(onProgress, report(4, "native", "system.beforeEvents.startup"), "done"); - - if (moduleRef && opts.boot !== false) { - // 入口副作用可能已 register;按 id 去重 - if (!ModuleRegistry.get(moduleRef.id)) { - ModuleRegistry.register(moduleRef); - } - progress(onProgress, report(6, "sfmc", "ModuleRegistry.bootAll"), "running"); - ModuleRegistry.bootAll(); - progress(onProgress, report(6, "sfmc", "ModuleRegistry.bootAll"), "done"); - - progress(onProgress, report(7, "sfmc", "snapshotEnabled"), "running"); - ModuleRegistry.snapshotEnabled(); - progress(onProgress, report(7, "sfmc", "snapshotEnabled"), "done"); - - progress(onProgress, report(8, "native", "(等待世界就绪)"), "running"); - progress(onProgress, report(8, "native", "(等待世界就绪)"), "done"); - - progress(onProgress, report(9, "native", "Dimension 默认可查询"), "running"); - progress(onProgress, report(9, "native", "Dimension 默认可查询"), "done"); - - progress(onProgress, report(10, "native", "world.afterEvents.worldLoad"), "running"); - eng.world.afterEvents.worldLoad!.emit({}); - progress(onProgress, report(10, "native", "world.afterEvents.worldLoad"), "done"); - - progress(onProgress, report(11, "sfmc", "bootAfterWorldLoad"), "running"); - ModuleRegistry.bootAfterWorldLoad(); - progress(onProgress, report(11, "sfmc", "bootAfterWorldLoad"), "done"); - } - - progress(onProgress, report(12, "ready", "就绪"), "done"); - - let disposed = false; - - const addPlayer = (init: Parameters[0]) => { - const userHook = init.onGameModeChange; - const userHealth = init.onHealthChange; - const userHurt = init.onHurt; - const userDie = init.onDie; - const p = createEnginePlayer({ - ...init, - onGameModeChange: (player, from, to) => { - userHook?.(player, from, to); - eng.world.beforeEvents.playerGameModeChange!.emit({ - player, - fromGameMode: from, - toGameMode: to, - cancel: false, - }); - eng.world.afterEvents.playerGameModeChange!.emit({ - player, - fromGameMode: from, - toGameMode: to, - }); - }, - onHealthChange: (player, oldValue, newValue) => { - userHealth?.(player, oldValue, newValue); - eng.world.afterEvents.entityHealthChanged!.emit({ - entity: player, - oldValue, - newValue, - }); - }, - onHurt: (player, damage, options) => { - userHurt?.(player, damage, options); - eng.world.afterEvents.entityHurt!.emit({ - hurtEntity: player, - damage, - damageSource: toDamageSource(options), - }); - }, - onDie: (player, options) => { - userDie?.(player, options); - eng.world.afterEvents.entityDie!.emit({ - deadEntity: player, - damageSource: toDamageSource(options), - }); - }, - }); - const dim = eng.world.getDimension(p.dimension.id); - p.dimension = dim; - eng.world.addPlayer(p); - return p; - }; - - const objects = createObjectRegistry({ - world: eng.world, - system: eng.system, - addPlayer, - }); - const events = createEventsDrive({ world: eng.world, system: eng.system, objects }); - - /* v3 manifest 解析:v2 → 自动 migrate;fixture.semantic 非空时合并进 manifest。 - * engine-only(无 moduleRoot)→ 返回 null。 */ - const moduleManifest = resolveModuleManifest(moduleRoot, opts.fixture?.semantic); - - const sb: Sandbox = { - world: eng.world, - system: eng.system, - ui: eng.ui, - db, - configAdapter, - module: moduleRef - ? moduleRoot - ? { id: moduleRef.id, root: moduleRoot } - : { id: moduleRef.id } - : null, - moduleManifest, - supported: SUPPORTED, - objects, - events, - addPlayer, - emit: { - playerJoin(player) { - eng.world.afterEvents.playerJoin!.emit({ - playerName: player.name, - playerId: player.id, - }); - }, - playerSpawn(player, spawnOpts) { - eng.world.afterEvents.playerSpawn!.emit({ - player, - initialSpawn: spawnOpts?.initialSpawn ?? true, - }); - }, - chatSend(player, message, chatOpts) { - eng.world.beforeEvents.chatSend!.emit({ - sender: player, - message, - cancel: false, - }); - if (chatOpts?.after) { - eng.world.afterEvents.chatSend!.emit({ sender: player, message }); - } - }, - scriptEvent(id, sourceEntity) { - eng.system.afterEvents.scriptEventReceive!.emit({ id, sourceEntity }); - }, - playerLeave(player) { - eng.world.removePlayer(player.id); - }, - itemUse(player, itemStack, opts) { - const stack = opts?.itemStack ?? itemStack ?? undefined; - const payload = { source: player, itemStack: stack, cancel: false }; - if (opts?.before) { - eng.world.beforeEvents.itemUse!.emit(payload); - } else { - eng.world.afterEvents.itemUse!.emit(payload); - } - }, - playerBreakBlock(player, opts) { - const payload = { - player, - block: opts?.block, - brokenBlockPermutation: opts?.brokenBlockPermutation, - itemStackBeforeBreak: opts?.itemStackBeforeBreak, - itemStackAfterBreak: opts?.itemStackAfterBreak, - cancel: false, - }; - if (opts?.before) { - eng.world.beforeEvents.playerBreakBlock!.emit(payload); - } else { - eng.world.afterEvents.playerBreakBlock!.emit(payload); - } - }, - playerPlaceBlock(player, opts) { - const block = - opts?.block ?? - player.dimension.getBlock(player.location); - const permutationToPlace = opts?.permutationToPlace; - const beforePayload = { - player, - block, - permutationToPlace, - face: opts?.face ?? "Up", - faceLocation: opts?.faceLocation ?? { x: 0.5, y: 1, z: 0.5 }, - cancel: false, - }; - eng.world.beforeEvents.playerPlaceBlock!.emit(beforePayload); - if (opts?.before) return; - if (beforePayload.cancel) return; - if (permutationToPlace) { - block.setPermutation(permutationToPlace); - } - eng.world.afterEvents.playerPlaceBlock!.emit({ - player, - block, - }); - }, - playerInteractWithBlock(player, opts) { - const block = opts?.block ?? player.dimension.getBlock(player.location); - const payload = { - player, - block, - blockFace: opts?.blockFace ?? "Up", - faceLocation: opts?.faceLocation ?? { x: 0.5, y: 1, z: 0.5 }, - itemStack: opts?.itemStack, - beforeItemStack: opts?.beforeItemStack ?? opts?.itemStack, - isFirstEvent: opts?.isFirstEvent ?? true, - cancel: false, - }; - if (opts?.before) { - eng.world.beforeEvents.playerInteractWithBlock!.emit(payload); - } else { - eng.world.afterEvents.playerInteractWithBlock!.emit(payload); - } - }, - playerInteractWithEntity(player, target, opts) { - if (opts?.before) { - eng.world.beforeEvents.playerInteractWithEntity!.emit({ - player, - target, - itemStack: opts?.itemStack, - cancel: false, - }); - } else { - eng.world.afterEvents.playerInteractWithEntity!.emit({ - player, - target, - itemStack: opts?.itemStack, - beforeItemStack: opts?.beforeItemStack ?? opts?.itemStack, - }); - } - }, - entityHitEntity(damagingEntity, hitEntity) { - eng.world.afterEvents.entityHitEntity!.emit({ damagingEntity, hitEntity }); - }, - }, - tick(n = 1) { - eng.system.tick(n); - }, - flush() { - eng.system.flush(); - }, - async triggerCommand(name, player) { - Command.trigger(player as never, name); - eng.system.flush(); - await Promise.resolve(); - eng.system.flush(); - await Promise.resolve(); - eng.system.flush(); - }, - async applyFixture(intent) { - return applyFixtureIntent( - { - adapter: configAdapter, - db, - moduleId: moduleRef?.id ?? null, - getPlayers: () => eng.world.getAllPlayers(), - }, - intent - ); - }, - clearDb() { - db.reset(); - }, - async dispose() { - if (disposed) return; - disposed = true; - if (moduleRef) { - try { - ModuleRegistry.cleanupModule(moduleRef.id); - } catch { - await runCleanup(moduleRef); - } - if (moduleRef.id) Command.unregisterByModule(moduleRef.id); - for (const n of Command.names()) { - Command.unregister(n); - } - Permission.clearRegistry(); - } - ModuleRegistry.resetForTesting(); - ConfigManager.resetForTesting(); - db.reset(); - disposeEngine(); - globalThis.__sfmcBdsSystem = undefined; - }, - }; - - return sb; -} - -export type { FormResponse } from "./engine/overrides/ui-host.js"; diff --git a/_test_archive/bds-tools/host-platform.test.mjs b/_test_archive/bds-tools/host-platform.test.mjs deleted file mode 100644 index b678b8ef..00000000 --- a/_test_archive/bds-tools/host-platform.test.mjs +++ /dev/null @@ -1,55 +0,0 @@ -/** - * host-platform 纯函数:Win / Linux 可执行文件名与 CDN 路径 - * 运行: npm test -w @sfmc-bds/bds-tools(需先 build) - */ -import assert from "node:assert/strict"; -import path from "node:path"; -import { describe, it } from "node:test"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -const pkgRoot = path.dirname(fileURLToPath(import.meta.url)); -const dist = path.join(pkgRoot, "dist", "host-platform.js"); - -async function loadHost() { - return import(pathToFileURL(dist).href); -} - -describe("host-platform", () => { - it("exe 名按宿主 OS", async () => { - const h = await loadHost(); - assert.equal(h.bdsExeName("windows"), "bedrock_server.exe"); - assert.equal(h.bdsExeName("linux"), "bedrock_server"); - assert.equal(h.bdsExePath("/opt/bds", "linux"), path.join("/opt/bds", "bedrock_server")); - }); - - it("详情 platform key 与 CDN suffix", async () => { - const h = await loadHost(); - assert.equal(h.bdsDetailsPlatformKey("release", "windows"), "windows"); - assert.equal(h.bdsDetailsPlatformKey("preview", "windows"), "windows_preview"); - assert.equal(h.bdsDetailsPlatformKey("release", "linux"), "linux"); - assert.equal(h.bdsDetailsPlatformKey("preview", "linux"), "linux_preview"); - assert.equal(h.bdsCdnZipSuffix("release", "1.21.0.3", "windows"), "/bin-win/bedrock-server-1.21.0.3.zip"); - assert.equal( - h.bdsCdnZipSuffix("preview", "1.21.0.3", "windows"), - "/bin-win-preview/bedrock-server-1.21.0.3-preview.zip" - ); - assert.equal(h.bdsCdnZipSuffix("release", "1.21.0.3", "linux"), "/bin-linux/bedrock-server-1.21.0.3.zip"); - assert.equal( - h.bdsCdnZipSuffix("preview", "1.21.0.3", "linux"), - "/bin-linux-preview/bedrock-server-1.21.0.3-preview.zip" - ); - }); - - it("Linux spawn env 含 LD_LIBRARY_PATH;Windows 为空", async () => { - const h = await loadHost(); - assert.deepEqual(h.bdsSpawnEnvExtra("C:\\BDS", "windows"), {}); - const linux = h.bdsSpawnEnvExtra("/opt/bds", "linux"); - assert.ok(String(linux.LD_LIBRARY_PATH).startsWith("/opt/bds")); - }); - - it("完整性检查文件含平台 exe", async () => { - const h = await loadHost(); - assert.deepEqual(h.bdsInstallRequiredFiles("windows"), ["bedrock_server.exe", "permissions.json"]); - assert.deepEqual(h.bdsInstallRequiredFiles("linux"), ["bedrock_server", "permissions.json"]); - }); -}); diff --git a/_test_archive/bds-tools/is-main.test.mjs b/_test_archive/bds-tools/is-main.test.mjs deleted file mode 100644 index f709aebd..00000000 --- a/_test_archive/bds-tools/is-main.test.mjs +++ /dev/null @@ -1,79 +0,0 @@ -/** - * is-main 契约测试:直接入口 / shim 入口 / 被异名入口 import - * 运行: npm test -w @sfmc-bds/bds-tools(需先 build) - * - * 注:仓库根 .gitignore 忽略名为 test 的路径,故用例放在包根而非 test/。 - */ - -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -import { after, describe, it } from "node:test"; -import { fileURLToPath } from "node:url"; - -const pkgRoot = path.dirname(fileURLToPath(import.meta.url)); -const distDir = path.join(pkgRoot, "dist"); - -/** 与 recovery.js → dist/recovery.js 同构:根目录 shim 与 dist 模块同名 */ -const MODULE_NAME = "_sfmc_is_main_fixture.js"; -const distModule = path.join(distDir, MODULE_NAME); -const rootShim = path.join(pkgRoot, MODULE_NAME); -const alienEntry = path.join(pkgRoot, "_sfmc_alien_entry.js"); - -const moduleSrc = ` -import { isMainModule } from "./is-main.js"; -console.log(isMainModule(import.meta.url) ? "MAIN" : "NOT_MAIN"); -`; - -const shimSrc = ` -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -await import(pathToFileURL(path.join(__dirname, "dist", ${JSON.stringify(MODULE_NAME)})).href); -`; - -const alienSrc = ` -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -await import(pathToFileURL(path.join(__dirname, "dist", ${JSON.stringify(MODULE_NAME)})).href); -`; - -describe("isMainModule", () => { - it("dist 直接作为 argv 入口时为 true", () => { - fs.mkdirSync(distDir, { recursive: true }); - fs.writeFileSync(distModule, moduleSrc); - const r = spawnSync(process.execPath, [distModule], { encoding: "utf8" }); - assert.equal(r.status, 0, r.stderr); - assert.equal(r.stdout.trim(), "MAIN"); - }); - - it("同名根目录 shim 动态 import dist 时为 true(recovery.js 契约)", () => { - fs.mkdirSync(distDir, { recursive: true }); - fs.writeFileSync(distModule, moduleSrc); - fs.writeFileSync(rootShim, shimSrc); - const r = spawnSync(process.execPath, [rootShim], { encoding: "utf8" }); - assert.equal(r.status, 0, r.stderr); - assert.equal(r.stdout.trim(), "MAIN"); - }); - - it("被异名入口 import 时为 false(check-update 加载 bds-manager)", () => { - fs.mkdirSync(distDir, { recursive: true }); - fs.writeFileSync(distModule, moduleSrc); - fs.writeFileSync(alienEntry, alienSrc); - const r = spawnSync(process.execPath, [alienEntry], { encoding: "utf8" }); - assert.equal(r.status, 0, r.stderr); - assert.equal(r.stdout.trim(), "NOT_MAIN"); - }); - - after(() => { - for (const p of [distModule, rootShim, alienEntry]) { - try { - fs.unlinkSync(p); - } catch { - /* ignore */ - } - } - }); -}); diff --git a/_test_archive/bds-tools/server-properties.test.mjs b/_test_archive/bds-tools/server-properties.test.mjs deleted file mode 100644 index 3399ead9..00000000 --- a/_test_archive/bds-tools/server-properties.test.mjs +++ /dev/null @@ -1,29 +0,0 @@ -import assert from "node:assert/strict"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import test from "node:test"; -import { ensureEmitServerTelemetry } from "../../packages/bds-tools/dist/server-properties.js"; - -test("ensureEmitServerTelemetry appends when missing", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-props-")); - const file = path.join(dir, "server.properties"); - fs.writeFileSync(file, "level-name=Bedrock level\n", "utf8"); - const msgs = []; - const wrote = ensureEmitServerTelemetry(dir, { info: (m) => msgs.push(m) }); - assert.equal(wrote, true); - const text = fs.readFileSync(file, "utf8"); - assert.match(text, /^emit-server-telemetry=true$/m); - assert.match(msgs[0] ?? "", /EULA/); - fs.rmSync(dir, { recursive: true, force: true }); -}); - -test("ensureEmitServerTelemetry skips when present", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-props-")); - const file = path.join(dir, "server.properties"); - fs.writeFileSync(file, "emit-server-telemetry=false\nlevel-name=x\n", "utf8"); - const wrote = ensureEmitServerTelemetry(dir); - assert.equal(wrote, false); - assert.equal(fs.readFileSync(file, "utf8"), "emit-server-telemetry=false\nlevel-name=x\n"); - fs.rmSync(dir, { recursive: true, force: true }); -}); diff --git a/_test_archive/cli/argv-parse.test.mjs b/_test_archive/cli/argv-parse.test.mjs deleted file mode 100644 index fa59aa7a..00000000 --- a/_test_archive/cli/argv-parse.test.mjs +++ /dev/null @@ -1,37 +0,0 @@ -// @ts-check -/** - * argv-parse.test.mjs - */ -import assert from "node:assert/strict"; -import test from "node:test"; -import { - isModuleInstallShorthand, - mapPacksSubAlias, - parseGlobalArgv, -} from "../../packages/cli/dist/argv-parse.js"; - -test("parseGlobalArgv 抽出 -p/--packs", () => { - const a = parseGlobalArgv(["-p", "i", "./x.zip"]); - assert.equal(a.packsMode, true); - assert.deepEqual(a.args, ["i", "./x.zip"]); - const b = parseGlobalArgv(["--packs", "list"]); - assert.equal(b.packsMode, true); - assert.deepEqual(b.args, ["list"]); - const c = parseGlobalArgv(["status"]); - assert.equal(c.packsMode, false); -}); - -test("mapPacksSubAlias i→install", () => { - assert.equal(mapPacksSubAlias("i"), "install"); - assert.equal(mapPacksSubAlias("list"), "list"); -}); - -test("isModuleInstallShorthand", () => { - assert.equal(isModuleInstallShorthand("i"), true); - assert.equal(isModuleInstallShorthand("install"), true); - assert.equal(isModuleInstallShorthand("uninstall"), true); - assert.equal(isModuleInstallShorthand("remove"), true); - assert.equal(isModuleInstallShorthand("search"), true); - assert.equal(isModuleInstallShorthand("status"), false); - assert.equal(isModuleInstallShorthand("mod"), false); -}); diff --git a/_test_archive/cli/command-palette.test.mjs b/_test_archive/cli/command-palette.test.mjs deleted file mode 100644 index 39d8c75c..00000000 --- a/_test_archive/cli/command-palette.test.mjs +++ /dev/null @@ -1,178 +0,0 @@ -// @ts-check -/** - * command-palette 补全器单测 - */ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { - clipPad, - commitSelection, - findMatchRange, - matchWeight, - PANEL_WIDTH, - parseSlashLine, - paletteGhost, - resolvePaletteView, -} from "../../packages/cli/dist/command-palette.js"; -import { visibleWidth } from "../../packages/cli/dist/logs.js"; -import { listNextPaletteTokens, listPaletteRoots } from "../../packages/cli/dist/command-surface.js"; - -describe("clipPad 等宽", () => { - it("垫到 PANEL_WIDTH", () => { - assert.equal(visibleWidth(clipPad("/start", PANEL_WIDTH)), PANEL_WIDTH); - }); -}); - -describe("parseSlashLine", () => { - it("/st → partial", () => { - assert.deepEqual(parseSlashLine("/st"), { - committed: [], - partial: "st", - trailingSpace: false, - }); - }); - it("/start → committed", () => { - assert.deepEqual(parseSlashLine("/start "), { - committed: ["start"], - partial: "", - trailingSpace: true, - }); - }); - it("/start b → 子 partial", () => { - assert.deepEqual(parseSlashLine("/start b"), { - committed: ["start"], - partial: "b", - trailingSpace: false, - }); - }); -}); - -describe("resolvePaletteView", () => { - it("仅 / 时单列且含多项", () => { - const v = resolvePaletteView("/", [0], [0]); - assert.equal(v.columns.length, 1); - assert.ok(v.columns[0].items.length > 1); - }); - it("/start 展开右侧且主列仍显示全部根", () => { - const v = resolvePaletteView("/start ", [0], [0]); - assert.ok(v.columns.length >= 2); - assert.ok(v.columns[0].items.length > 1); - assert.ok(v.columns[1].items.some((n) => n.token === "bds")); - }); - it("/st 不删项,匹配置顶", () => { - const all = listPaletteRoots("repl").length; - const v = resolvePaletteView("/st", [0], [0]); - assert.equal(v.columns[0].items.length, all); - assert.ok(v.columns[0].items[0].token.startsWith("st")); - assert.ok(matchWeight(v.columns[0].items[0], "st") > 0); - }); - it("/send bds 自由参关闭面板", () => { - const v = resolvePaletteView("/send bds ", [0], [0]); - assert.equal(v.hidden, true); - }); - it("/send bds 后继续打字仍关闭面板", () => { - const v = resolvePaletteView("/send bds say hi", [0], [0]); - assert.equal(v.hidden, true); - }); - it("叶命令 /status 到末尾立即关闭", () => { - assert.equal(resolvePaletteView("/status", [0], [0]).hidden, true); - }); - it("叶命令 /start bds 到末尾立即关闭", () => { - assert.equal(resolvePaletteView("/start bds", [0], [0]).hidden, true); - }); - it("/ui 展开 studio 与 stop", () => { - const v = resolvePaletteView("/ui ", [0], [0]); - assert.equal(v.hidden, false); - const tokens = v.columns.at(-1).items.map((n) => n.token); - assert.deepEqual(tokens, ["studio", "stop"]); - }); - it("/packs doctor 展开实验性开关", () => { - const v = resolvePaletteView("/packs doctor ", [0], [0]); - assert.equal(v.hidden, false); - const tokens = v.columns.at(-1).items.map((n) => n.token); - assert.ok(tokens.includes("--fix")); - assert.ok(tokens.includes("--all-experiments")); - assert.ok(tokens.includes("--experiments=")); - assert.ok(tokens.includes("")); - }); - it("doctor 直接运行提交无参命令", () => { - const v = resolvePaletteView("/packs doctor ", [0], [0]); - const col = v.columns.at(-1); - const idx = col.items.findIndex((n) => n.token === ""); - const view = resolvePaletteView("/packs doctor ", [0, 0, idx], [0, 0, 0]); - const acc = commitSelection("/packs doctor ", view); - assert.equal(acc.line, "/packs doctor"); - assert.equal(acc.submit, true); - }); - it("--experiments= 粘在同一 token 上且随后收起面板", () => { - const v0 = resolvePaletteView("/packs doctor ", [0], [0]); - const idx = v0.columns.at(-1).items.findIndex((n) => n.token === "--experiments="); - const view = resolvePaletteView("/packs doctor ", [0, 0, idx], [0, 0, 0]); - const acc = commitSelection("/packs doctor ", view); - assert.equal(acc.line, "/packs doctor --experiments="); - assert.equal(acc.submit, false); - assert.equal(resolvePaletteView("/packs doctor --experiments=beta,upcoming", [0], [0]).hidden, true); - }); - it("灰字补全与面板共用 ui / doctor 的下一参", () => { - assert.deepEqual(listNextPaletteTokens(["ui"]), ["studio", "stop"]); - const doctor = listNextPaletteTokens(["packs", "doctor"]); - assert.ok(doctor?.includes("--all-experiments")); - assert.ok(doctor?.includes("--fix")); - assert.equal(doctor?.includes(""), false); - }); - it("未到末尾 /start 仍显示", () => { - assert.equal(resolvePaletteView("/start", [0], [0]).hidden, false); - assert.ok(resolvePaletteView("/start", [0], [0]).columns.length >= 1); - }); - it("半词 /st 未到末尾仍显示", () => { - assert.equal(resolvePaletteView("/st", [0], [0]).hidden, false); - }); -}); - -describe("commitSelection", () => { - it("选 start → 写入并补空格,不提交", () => { - const roots = listPaletteRoots("repl"); - const startIdx = roots.findIndex((n) => n.token === "start"); - const view = resolvePaletteView("/", [startIdx], [0]); - const acc = commitSelection("/", view); - assert.equal(acc.line, "/start "); - assert.equal(acc.submit, false); - }); - it("选 status → 写入无空格,不提交", () => { - const roots = listPaletteRoots("repl"); - const idx = roots.findIndex((n) => n.token === "status"); - const view = resolvePaletteView("/", [idx], [0]); - const acc = commitSelection("/", view); - assert.equal(acc.line, "/status"); - assert.equal(acc.submit, false); - }); - it("再次回车 status → 提交", () => { - const view = resolvePaletteView("/status", [0], [0]); - const acc = commitSelection("/status", view); - assert.equal(acc.submit, true); - }); - it("子列 bds → 写入不执行", () => { - const v0 = resolvePaletteView("/start ", [0, 0], [0, 0]); - const bdsIdx = v0.columns[1].items.findIndex((n) => n.token === "bds"); - const view = resolvePaletteView("/start ", [0, bdsIdx], [0, 0]); - const acc = commitSelection("/start ", view); - assert.equal(acc.line, "/start bds"); - assert.equal(acc.submit, false); - }); -}); - -describe("paletteGhost", () => { - it("partial st 置顶项给出后缀灰字", () => { - const view = resolvePaletteView("/st", [0], [0]); - const top = view.columns[0].items[0].token; - assert.ok(top.startsWith("st")); - assert.equal(paletteGhost(view), top.slice(2)); - }); -}); - -describe("findMatchRange", () => { - it("匹配 label 前缀", () => { - assert.deepEqual(findMatchRange("/start", "st"), { start: 1, end: 3 }); - assert.equal(findMatchRange("/status", "xyz"), null); - }); -}); diff --git a/_test_archive/cli/directory-picker.test.mjs b/_test_archive/cli/directory-picker.test.mjs deleted file mode 100644 index 4359133f..00000000 --- a/_test_archive/cli/directory-picker.test.mjs +++ /dev/null @@ -1,79 +0,0 @@ -/** - * directory-picker.test.mjs — 原生目录选择器命令组装(不弹真实对话框) - */ -import assert from "node:assert/strict"; -import path from "node:path"; -import test from "node:test"; -import { - buildDirectoryPickerCandidates, - buildMacOsPickerScript, - buildWindowsPickerScript, - escapeAppleScriptString, - escapePowerShellSingleQuoted, - resolvePickerStartDir, -} from "../../packages/cli/dist/directory-picker.js"; - -test("PowerShell 单引号转义", () => { - assert.equal(escapePowerShellSingleQuoted("a'b"), "a''b"); -}); - -test("AppleScript 字符串转义", () => { - assert.equal(escapeAppleScriptString(String.raw`a\b"c`), String.raw`a\\b\"c`); -}); - -test("Windows 候选含 powershell 与 pwsh", () => { - const candidates = buildDirectoryPickerCandidates("win32", "Pick BDS", "D:/games/BDS"); - assert.equal(candidates.length, 2); - assert.equal(candidates[0].command, "powershell.exe"); - assert.equal(candidates[1].command, "pwsh"); - assert.match(candidates[0].args.at(-1), /FolderBrowserDialog/); -}); - -test("Linux 默认 zenity 优先,KDE 桌面 kdialog 优先", () => { - const gnome = buildDirectoryPickerCandidates("linux", "Pick dir", "/tmp/sfmc"); - assert.deepEqual( - gnome.map((c) => c.command), - ["zenity", "kdialog"] - ); - assert.deepEqual(gnome[0].args.slice(0, 3), ["--file-selection", "--directory", "--title=Pick dir"]); - - const prev = process.env.XDG_CURRENT_DESKTOP; - process.env.XDG_CURRENT_DESKTOP = "KDE"; - try { - const kde = buildDirectoryPickerCandidates("linux", "Pick dir", "/tmp/sfmc"); - assert.deepEqual( - kde.map((c) => c.command), - ["kdialog", "zenity"] - ); - } finally { - if (prev === undefined) delete process.env.XDG_CURRENT_DESKTOP; - else process.env.XDG_CURRENT_DESKTOP = prev; - } -}); - -test("macOS 使用 osascript choose folder", () => { - const [candidate] = buildDirectoryPickerCandidates("darwin", "Pick dir", "/Users/test/SFMC"); - assert.equal(candidate.command, "osascript"); - const script = candidate.args.at(-1); - assert.match(script, /choose folder with prompt "Pick dir"/); - assert.match(script, /default location defaultFolder/); - - const direct = buildMacOsPickerScript("Pick dir", "/Users/test/SFMC"); - assert.match(direct, /POSIX file "\/Users\/test\/SFMC"/); -}); - -test("resolvePickerStartDir 在默认目录不存在时回退", () => { - const start = resolvePickerStartDir(path.join(process.cwd(), "__definitely_missing_dir__")); - assert.equal(start, process.cwd()); -}); - -test("buildWindowsPickerScript 保留描述与起始路径", () => { - const script = buildWindowsPickerScript("BDS 目录", "D:/WorkPlace/SFMC/BDS"); - assert.match(script, /Description = 'BDS 目录'/); - assert.match(script, /SelectedPath = 'D:\/WorkPlace\/SFMC\/BDS'/); -}); - -test("buildMacOsPickerScript 含取消处理", () => { - const script = buildMacOsPickerScript("title", "/tmp"); - assert.match(script, /error number -128/); -}); diff --git a/_test_archive/cli/llbot-launch.test.mjs b/_test_archive/cli/llbot-launch.test.mjs deleted file mode 100644 index 2427d08e..00000000 --- a/_test_archive/cli/llbot-launch.test.mjs +++ /dev/null @@ -1,40 +0,0 @@ -/** - * llbot-launch.test.mjs — resolveLlbotLaunch 兼容目录/可执行文件 - */ -import assert from "node:assert/strict"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import test from "node:test"; -import { llbotExeName, resolveLlbotLaunch } from "../../packages/cli/dist/llbot-launch.js"; - -test("llbot_path 为目录时拼平台 exe 名", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-llbot-")); - try { - const { exe, cwd } = resolveLlbotLaunch(dir, ""); - assert.equal(cwd, dir); - assert.equal(exe, path.join(dir, llbotExeName())); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test("llbot_path 为 exe 时尊重 cwd", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-llbot-")); - const exePath = path.join(dir, llbotExeName()); - fs.writeFileSync(exePath, ""); - try { - const { exe, cwd } = resolveLlbotLaunch(exePath, dir); - assert.equal(exe, exePath); - assert.equal(cwd, dir); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test("空 path 用默认目录 + cwd 覆盖", () => { - const { exe, cwd } = resolveLlbotLaunch("", "D:/custom-llbot"); - assert.equal(cwd, "D:/custom-llbot"); - const expected = `D:/custom-llbot/${llbotExeName()}`.replace(/\\/g, "/"); - assert.equal(exe.replace(/\\/g, "/"), expected); -}); diff --git a/_test_archive/cli/repl-windows.test.mjs b/_test_archive/cli/repl-windows.test.mjs deleted file mode 100644 index c9bef1ad..00000000 --- a/_test_archive/cli/repl-windows.test.mjs +++ /dev/null @@ -1,155 +0,0 @@ -// @ts-check -/** - * repl-windows.test.mjs — WindowHost / ServiceWindow / formatLogDisplay / disk logs - */ -import assert from "node:assert/strict"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import test from "node:test"; -import { formatLogDisplay, logDisplayPrefixWidth } from "../../packages/cli/dist/repl-windows/format-display.js"; -import { WindowHost, serviceWindowId } from "../../packages/cli/dist/repl-windows/host.js"; -import { createLogsFilterWindow } from "../../packages/cli/dist/repl-windows/logs-filter-window.js"; -import { createServiceWindow } from "../../packages/cli/dist/repl-windows/service-window.js"; -import { - diskFileForSource, - formatLog, - logPrefixWidth, - parseDiskLogLine, - readDiskLogs, - visibleWidth, -} from "../../packages/cli/dist/logs.js"; -import { plainPrompt } from "../../packages/cli/dist/send-target.js"; - -function makeLog(source, level = "info", text = "hello") { - return { time: new Date(0), source, level, text }; -} - -test("WindowHost cycle/open/back", () => { - const lines = []; - const host = new WindowHost({ writeLog: (l) => lines.push(l) }); - host.register(createServiceWindow("bds")); - host.register(createServiceWindow("db")); - host.register( - createLogsFilterWindow({ - getState: () => ({ levels: [], sources: [] }), - setState: () => {}, - onFilterChanged: () => {}, - }) - ); - host.setServiceOrder([serviceWindowId("bds"), serviceWindowId("db")]); - assert.equal(host.getActive()?.serviceName, "bds"); - - assert.equal(host.cycleServiceWindows(), "db"); - assert.equal(host.getActive()?.serviceName, "db"); - assert.equal(host.cycleServiceWindows(), "bds"); - - assert.ok(host.open("logs")); - assert.equal(host.isLogsActive(), true); - assert.equal(host.getChrome().showsInput, false); - assert.ok(host.back()); - assert.equal(host.getActive()?.serviceName, "bds"); -}); - -test("ServiceWindow.acceptLog 仅同 source", () => { - const win = createServiceWindow("db"); - assert.equal(win.acceptLog(makeLog("db")), true); - assert.equal(win.acceptLog(makeLog("bds")), false); - const { text } = win.formatLogLine(makeLog("db", "info", "x")); - assert.ok(!text.includes("[DB"), "服务窗展示应省略源标签"); - assert.ok(text.includes("[INF]")); -}); - -test("formatLogDisplay omitSource 宽度与落盘 formatLog 分离", () => { - const log = makeLog("bds", "warn", "body"); - const disk = formatLog(log); - const tty = formatLogDisplay(log, { omitSource: true }); - assert.ok(disk.includes("[BDS]") || disk.includes("BDS"), "落盘/全量格式仍带源"); - assert.ok(!tty.includes("[BDS]") && !tty.includes("[BDS"), "omitSource 无源标签"); - const wOmit = logDisplayPrefixWidth(log, { omitSource: true }); - const wFull = logDisplayPrefixWidth(log, { omitSource: false }); - assert.ok(wFull > wOmit); - assert.equal(typeof logPrefixWidth(log), "number"); - assert.ok(visibleWidth(tty) >= wOmit); -}); - -test("LogsFilterWindow L/S/Esc", () => { - let state = { levels: [], sources: [] }; - let changed = 0; - const win = createLogsFilterWindow({ - getState: () => state, - setState: (n) => { - state = n; - }, - onFilterChanged: () => { - changed++; - }, - }); - assert.equal(win.showsInput, false); - assert.equal(win.acceptLog(makeLog("bds", "error")), true); - win.onKey({ type: "char", ch: "l" }); - assert.deepEqual(state.levels, ["info"]); - assert.equal(changed, 1); - assert.equal(win.acceptLog(makeLog("bds", "error")), false); - assert.equal(win.onKey({ type: "escape" }).action, "back"); -}); - -test("WindowHost 缓冲:切窗保留缓存,routeLog 扇出到已 seed 窗", () => { - const tty = []; - const host = new WindowHost({ writeLog: (l) => tty.push(l) }); - host.register(createServiceWindow("bds")); - host.register(createServiceWindow("db")); - host.setServiceOrder([serviceWindowId("bds"), serviceWindowId("db")]); - - host.setBuffer(serviceWindowId("bds"), ["bds-seed"]); - host.setBuffer(serviceWindowId("db"), ["db-seed"]); - - host.routeLog(makeLog("bds", "info", "live-bds")); - host.routeLog(makeLog("db", "info", "live-db")); - - assert.equal(tty.length, 1, "仅活动窗 bds 刷 TTY"); - assert.ok(host.getBuffer(serviceWindowId("bds")).some((l) => l.includes("live-bds") || l.length > 0)); - assert.ok(host.getBuffer(serviceWindowId("db")).length >= 1); - - assert.equal(host.cycleServiceWindows(), "db"); - assert.deepEqual([...host.getBuffer(serviceWindowId("bds"))].slice(0, 1), ["bds-seed"]); - assert.ok(host.hasBuffer(serviceWindowId("db"))); -}); - -test("plainPrompt 无斜杠", () => { - const p = plainPrompt(); - assert.ok(!p.includes("/"), `prompt should not contain slash: ${JSON.stringify(p)}`); - assert.ok(p.includes("❯")); -}); - -test("parseDiskLogLine / readDiskLogs 从文件拉取", () => { - assert.equal(diskFileForSource("update"), "bds-update"); - assert.equal(diskFileForSource("system"), "sfmc"); - - const parsed = parseDiskLogLine("2026-07-28 01:02:03 [db] [ERROR] boom"); - assert.ok(parsed); - assert.equal(parsed.source, "db"); - assert.equal(parsed.level, "error"); - assert.equal(parsed.text, "boom"); - assert.equal(parseDiskLogLine("not a log"), null); - - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-read-disk-")); - try { - fs.writeFileSync( - path.join(dir, "db.log"), - "2026-07-28 01:02:03 [db] [INFO] a\n2026-07-28 01:02:05 [db] [WARN] b\n", - "utf8" - ); - fs.writeFileSync(path.join(dir, "bds.log"), "2026-07-28 01:02:04 [bds] [INFO] mid\n", "utf8"); - const all = readDiskLogs({ dir }); - assert.equal(all.length, 3); - assert.equal(all[0].text, "a"); - assert.equal(all[1].text, "mid"); - assert.equal(all[2].text, "b"); - const onlyErr = readDiskLogs({ dir, levels: ["warn"], sources: ["db"] }); - assert.equal(onlyErr.length, 1); - assert.equal(onlyErr[0].text, "b"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/_test_archive/cli/terminal-progress.test.mjs b/_test_archive/cli/terminal-progress.test.mjs deleted file mode 100644 index ae0c30e2..00000000 --- a/_test_archive/cli/terminal-progress.test.mjs +++ /dev/null @@ -1,200 +0,0 @@ -// @ts-check -/** - * terminal-progress / bindByteProgressToBar 契约测试 - * 从 @sfmc-bds/sdk/logs 导入权威实现(需 workspace build)。 - */ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { Writable } from "node:stream"; -import { - bindByteProgressToBar, - createTerminalProgress, - pauseAllProgress, - resumeAllProgress, -} from "@sfmc-bds/sdk/logs"; - -describe("progress pause/resume contract (registry)", () => { - it("嵌套 pause 只在最外层 resume 时重绘", () => { - let writes = 0; - const stream = new Writable({ - write(_chunk, _e, cb) { - writes++; - cb(); - }, - }); - const bar = createTerminalProgress({ stream, forceBar: true }); - bar.start(10, 0, { speed: "0 KB/s" }); - const afterStart = writes; - assert.ok(afterStart >= 1, "start 应至少绘制一次"); - - pauseAllProgress(); - const afterOuterPause = writes; - assert.ok(afterOuterPause > afterStart, "外层 pause 应清行"); - - pauseAllProgress(); - assert.equal(writes, afterOuterPause, "嵌套 pause 不应再写流"); - - /* 嵌套 pause:内层 resume 不应重绘(pausedDepth 仍 >0) */ - resumeAllProgress(); - assert.equal(writes, afterOuterPause, "内层 resume 不应重绘"); - assert.equal(bar.active, true); - - resumeAllProgress(); - assert.ok(writes > afterOuterPause, "最外层 resume 应重绘"); - assert.equal(bar.active, true); - bar.stop(); - }); -}); - -describe("bindByteProgressToBar LSP(未知总量→已知)", () => { - it("无 Content-Length 中途 total=0,finish 补总量时 setTotal 修正刻度", () => { - const starts = []; - const setTotals = []; - const updates = []; - const bar = { - active: true, - start(total, value = 0, payload) { - starts.push({ total, value, payload }); - }, - setTotal(total) { - setTotals.push(total); - }, - update(value, payload) { - updates.push({ value, payload }); - }, - stop() {}, - }; - const seen = []; - const onByte = bindByteProgressToBar(bar, { - speedSampleMs: 0, - onProgress: (dl, total) => seen.push([dl, total]), - }); - - /* 中途无 Content-Length:首帧 startValue=已下载量 */ - onByte(512 * 1024, 0); - assert.equal(starts.length, 1); - assert.equal(starts[0].total, 1); /* 占位 1MB */ - assert.equal(starts[0].value, 0.5, "首帧 startValue 须反映已下载字节"); - assert.equal(updates.length, 1, "首帧须 update"); - assert.equal(updates[0].value, 0.5); - assert.equal(seen[0][0], 512 * 1024); - assert.equal(seen[0][1], 0); - - /* finish:用 finalBytes 作总量(与 httpDownload LSP 契约一致) */ - const finalBytes = 5 * 1024 * 1024; - onByte(finalBytes, finalBytes); - assert.equal(starts.length, 1, "已知总量后不得再 start"); - assert.equal(setTotals.length, 1, "总量从未知变为已知时应 setTotal"); - assert.equal(setTotals[0], 5); - assert.equal(updates.length, 2, "setTotal 后仍须 update"); - assert.equal(updates[1].value, 5); - assert.deepEqual(seen[1], [finalBytes, finalBytes]); - }); - - it("一开始就有 Content-Length 时不重复 start/setTotal,且首帧带已下载量", () => { - const starts = []; - const setTotals = []; - const updates = []; - const bar = { - active: true, - start(total, value = 0) { - starts.push({ total, value }); - }, - setTotal(total) { - setTotals.push(total); - }, - update(value) { - updates.push(value); - }, - stop() {}, - }; - const onByte = bindByteProgressToBar(bar, { speedSampleMs: 0 }); - onByte(1024, 10 * 1024 * 1024); - onByte(2 * 1024 * 1024, 10 * 1024 * 1024); - assert.equal(starts.length, 1); - assert.equal(starts[0].total, 10); - assert.ok(starts[0].value > 0, "首帧 startValue > 0"); - assert.equal(setTotals.length, 0, "一开始已知总量时不应 setTotal"); - assert.equal(updates.length, 2); - assert.ok(updates[0] > 0); - }); - - it("createTerminalProgress 非 TTY:start 尊重 startValue;setTotal 后 update 出正确刻度", () => { - const logs = []; - const stream = new Writable({ - write(_chunk, _e, cb) { - cb(); - }, - }); - /* forceBar:false + logger → 非 TTY 路径 */ - const bar = createTerminalProgress({ - stream, - forceBar: false, - logger: (m) => logs.push(m), - }); - bar.start(5, 5, { speed: "1 MB/s" }); - assert.ok(logs.length >= 1); - assert.match(logs[0], /100%/); - assert.match(logs[0], /5(?:\.0)?\/5/); - - logs.length = 0; - /* 占位刻度 → setTotal 修正后由 update 打一条,勿虚假 0% */ - const bar2 = createTerminalProgress({ - stream, - forceBar: false, - logger: (m) => logs.push(m), - }); - bar2.start(1, 0.5, { speed: "0 B/s" }); - assert.match(logs[0], /50%/); - logs.length = 0; - bar2.setTotal(5); - assert.equal(logs.length, 0, "setTotal 本身不打日志(留给 update)"); - bar2.update(5, { speed: "1 MB/s" }); - assert.equal(logs.length, 1); - assert.match(logs[0], /100%/); - assert.match(logs[0], /5(?:\.0)?\/5/); - bar.stop(); - bar2.stop(); - }); - - it("binder+非 TTY:首帧不出现虚假 0%,finish 经 setTotal 收 100%", () => { - const logs = []; - const stream = new Writable({ - write(_chunk, _e, cb) { - cb(); - }, - }); - const bar = createTerminalProgress({ - stream, - forceBar: false, - logger: (m) => logs.push(m), - }); - const onByte = bindByteProgressToBar(bar, { speedSampleMs: 0 }); - onByte(512 * 1024, 0); - assert.ok(logs.length >= 1); - assert.ok(!logs.some((m) => /^进度 0%/.test(m)), "首帧不得虚假 0%"); - assert.match(logs[0], /50%/); - - logs.length = 0; - const finalBytes = 5 * 1024 * 1024; - onByte(finalBytes, finalBytes); - assert.ok(logs.some((m) => /100%/.test(m))); - bar.stop(); - }); - - it("createTerminalProgress + binder 在 forceBar 下可跑通收尾", () => { - const chunks = []; - const stream = new Writable({ - write(chunk, _e, cb) { - chunks.push(String(chunk)); - cb(); - }, - }); - const bar = createTerminalProgress({ stream, forceBar: true }); - const onByte = bindByteProgressToBar(bar, { speedSampleMs: 0 }); - onByte(100, 0); - onByte(2048, 2048); - bar.stop(); - assert.ok(chunks.length > 0); - }); -}); diff --git a/_test_archive/cli/wrap-log-line.test.mjs b/_test_archive/cli/wrap-log-line.test.mjs deleted file mode 100644 index ce85009c..00000000 --- a/_test_archive/cli/wrap-log-line.test.mjs +++ /dev/null @@ -1,156 +0,0 @@ -// @ts-check -/** - * wrap-log-line.test.mjs — 悬挂缩进:显式 \\n + 多行软换行均须对齐 - */ -import assert from "node:assert/strict"; -import test from "node:test"; -import { logPrefixWidth, visibleWidth, wrapLogLine } from "../../packages/cli/dist/logs.js"; - -function stripAnsi(s) { - return s.replace(/\x1b\[[0-9;]*m/g, ""); -} - -test("显式换行后的续行带悬挂缩进", () => { - const prev = process.stdout.columns; - process.stdout.columns = 80; - try { - const indent = 26; - const input = `${"x".repeat(10)}\n https://example.com/abc`; - const out = stripAnsi(wrapLogLine(input, indent)); - const lines = out.split("\n"); - assert.equal(lines.length, 2); - assert.ok(lines[1].startsWith(" ".repeat(indent))); - assert.ok(lines[1].includes("https://example.com/abc")); - assert.equal(lines[1].trimStart(), "https://example.com/abc"); - } finally { - process.stdout.columns = prev; - } -}); - -test("软换行三行以上每行都悬挂缩进", () => { - const prev = process.stdout.columns; - process.stdout.columns = 40; - try { - const indent = 10; - const body = "A".repeat(100); - const out = stripAnsi(wrapLogLine(body, indent)); - const lines = out.split("\n"); - assert.ok(lines.length >= 3, `expected >=3 lines, got ${lines.length}`); - assert.equal(lines[0][0], "A"); - for (let i = 1; i < lines.length; i++) { - assert.ok( - lines[i].startsWith(" ".repeat(indent)), - `line ${i} missing indent: ${JSON.stringify(lines[i].slice(0, 20))}` - ); - assert.ok(visibleWidth(lines[i]) <= 39, `line ${i} width ${visibleWidth(lines[i])} > 39`); - } - } finally { - process.stdout.columns = prev; - } -}); - -test("显式换行后再软折:第三行仍缩进", () => { - const prev = process.stdout.columns; - process.stdout.columns = 40; - try { - const indent = 8; - const input = `short\n${"B".repeat(80)}`; - const out = stripAnsi(wrapLogLine(input, indent)); - const lines = out.split("\n"); - assert.ok(lines.length >= 3); - assert.equal(lines[0], "short"); - for (let i = 1; i < lines.length; i++) { - assert.ok(lines[i].startsWith(" ".repeat(indent)), `line ${i} no hang indent`); - } - } finally { - process.stdout.columns = prev; - } -}); - -test("logPrefixWidth 与常见源标签匹配", () => { - const log = { - time: new Date("2026-07-25T00:48:54"), - text: "hello", - source: "pack", - level: "success", - }; - const w = logPrefixWidth(log); - /* HH:MM:SS + space + [PAK] + space + [SUC] + space → 约 21 */ - assert.ok(w >= 20 && w <= 24, `prefix width unexpected: ${w}`); -}); - -test("resolveDisplayLevel:BDS 行从正文解析,其余用 entry.level", async () => { - const { resolveDisplayLevel, inferLevel, parseBdsEmbeddedLevel, stripBdsLogPrefix, pushLog, getAllLogs } = - await import("../../packages/cli/dist/logs.js"); - assert.equal( - resolveDisplayLevel({ - time: new Date(), - text: "[2026-07-18 23:56:06:778 ERROR] boom", - source: "bds", - level: "info", - }), - "error" - ); - /* Bedrock 常用 WARN;亦识别纯小写 warn */ - assert.equal( - parseBdsEmbeddedLevel( - "[2026-07-25 18:42:27:626 WARN] [Commands] Error on line 4: command failed to parse" - ), - "warn" - ); - assert.equal( - parseBdsEmbeddedLevel("[2026-07-25 18:42:27:626 warn] [Commands] lowercase level"), - "warn" - ); - assert.equal( - parseBdsEmbeddedLevel("[2026-07-25 18:42:27:626 error] boom"), - "error" - ); - assert.equal( - stripBdsLogPrefix( - "[2026-07-25 18:42:27:626 WARN] [Commands] Error on line 4: command failed to parse" - ), - "[Commands] Error on line 4: command failed to parse" - ); - assert.equal( - stripBdsLogPrefix("[2026-07-25 18:42:27:626 warn] [Commands] lowercase level"), - "[Commands] lowercase level" - ); - assert.equal( - resolveDisplayLevel({ - time: new Date(), - text: "[2026-07-25 18:42:27:626 WARN] [Commands] Error on line 4: command failed to parse", - source: "bds", - level: "info", - }), - "warn" - ); - assert.equal(inferLevel("[2026-07-25 18:42:27:626 WARN] [Commands] Syntax error"), "warn"); - assert.equal(inferLevel("[2026-07-25 18:42:27:626 warn] [Commands] Syntax error"), "warn"); - assert.equal(inferLevel("[2026-07-25 18:42:27:626 ERROR] something broke"), "error"); - - /* pushLog:等级入库 + 正文剥前缀 */ - const before = getAllLogs().length; - pushLog( - "[2026-07-25 18:42:27:626 warn] [Commands] Error on line 4: unexpected /", - "bds", - "info" - ); - const last = getAllLogs().at(-1); - assert.ok(last); - assert.equal(last.level, "warn"); - assert.equal(last.text, "[Commands] Error on line 4: unexpected /"); - /* 入库后正文含 Error 字样,不得把展示级别抬成 error(旧 getLogLevel 松散匹配的坑) */ - assert.equal(resolveDisplayLevel(last), "warn"); - assert.ok(getAllLogs().length === before + 1); - - assert.equal( - resolveDisplayLevel({ - time: new Date(), - text: "ok", - source: "pack", - level: "success", - }), - "success" - ); -}); diff --git a/_test_archive/sdk/conformance.test.mjs b/_test_archive/sdk/conformance.test.mjs deleted file mode 100644 index c23e356b..00000000 --- a/_test_archive/sdk/conformance.test.mjs +++ /dev/null @@ -1,89 +0,0 @@ -/** - * conformance.test.mjs — 假引擎保真套件(须配合 minecraft-loader) - */ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { - createSandbox, - assertMsg, -} from "../../modules/sdk/@sfmc-sdk/dist/esm/testing/index.js"; -import { system, world } from "@minecraft/server"; -import { ActionFormData } from "@minecraft/server-ui"; -import { Command, Msg, Permission } from "../../modules/sdk/@sfmc-sdk/dist/esm/sapi/runtime/index.js"; - -test("system.runTimeout 在 tick 后触发", async () => { - const sb = await createSandbox(); - let hit = false; - system.runTimeout(() => { - hit = true; - }, 2); - sb.tick(1); - assert.equal(hit, false); - sb.tick(1); - assert.equal(hit, true); - await sb.dispose(); -}); - -test("world.beforeEvents.chatSend emit 触发订阅", async () => { - const sb = await createSandbox(); - const player = sb.addPlayer({ name: "Alice", op: true }); - const seen = []; - world.beforeEvents.chatSend.subscribe((ev) => { - seen.push(ev.message); - }); - world.beforeEvents.chatSend.emit({ - sender: player, - message: "hi", - cancel: false, - }); - assert.deepEqual(seen, ["hi"]); - await sb.dispose(); -}); - -test("createSandbox boot 命令路径", async () => { - const descriptor = { - id: "feature-conformance", - afterWorldLoad: false, - lifecycle: { - registerPermissions() { - Permission.register("c.use", Permission.Any); - }, - registerCommands() { - Command.register( - "ping", - "c.use", - (player) => { - if (player) Msg.info("pong", player); - }, - "ping", - "feature-conformance" - ); - }, - cleanup() {}, - }, - }; - const sb = await createSandbox({ module: descriptor }); - const player = sb.addPlayer({ name: "Bob", op: true }); - await sb.triggerCommand("ping", player); - assert.equal(assertMsg(player, "pong"), true); - await sb.dispose(); -}); - -test("ui.queueResponse 喂给 ActionFormData.show", async () => { - const sb = await createSandbox(); - const player = sb.addPlayer({ name: "Carol" }); - sb.ui.queueResponse(player, { canceled: false, selection: 1 }); - const form = new ActionFormData().title("t").button("a").button("b"); - const res = await form.show(player); - assert.equal(res.canceled, false); - assert.equal(res.selection, 1); - await sb.dispose(); -}); - -test("未实现 API 抛错", async () => { - const sb = await createSandbox(); - assert.throws(() => { - void /** @type {any} */ (world).definitelyNotReal; - }, /未实现的 Minecraft API/); - await sb.dispose(); -}); diff --git a/_test_archive/sdk/emit-callbacks.test.mjs b/_test_archive/sdk/emit-callbacks.test.mjs deleted file mode 100644 index 575379f8..00000000 --- a/_test_archive/sdk/emit-callbacks.test.mjs +++ /dev/null @@ -1,121 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { createSandbox } from "./dist/esm/testing/index.js"; -import { world } from "@minecraft/server"; - -test("events.emit 同步调用 @minecraft/server 上模块 subscribe 的回调", async () => { - let hits = 0; - /** @type {unknown} */ - let lastEv = null; - const sb = await createSandbox({ - module: { - id: "emit-cb-module", - afterWorldLoad: false, - lifecycle: { - registerEvents() { - world.afterEvents.playerJoin.subscribe((ev) => { - hits++; - lastEv = ev; - }); - }, - }, - }, - }); - try { - assert.equal(sb.events.listenerCount("world.afterEvents.playerJoin"), 1); - assert.equal(sb.world.afterEvents.playerJoin, world.afterEvents.playerJoin); - - const payload = { playerName: "Neo", playerId: "p1" }; - const returned = sb.events.emit("world.afterEvents.playerJoin", payload); - assert.equal(hits, 1); - assert.equal(returned, payload); // 无 $ref 时保持同一引用 - assert.equal(lastEv, payload); - - const meta = sb.events.lastMeta(); - assert.ok(meta); - assert.equal(meta.listeners, 1); - assert.equal(meta.errors.length, 0); - } finally { - await sb.dispose(); - } -}); - -test("events.emit 回调抛错仍继续调用其余订阅,并记入 lastMeta.errors", async () => { - const order = []; - const sb = await createSandbox({ - module: { - id: "emit-err-module", - afterWorldLoad: false, - lifecycle: { - registerEvents() { - world.beforeEvents.chatSend.subscribe(() => { - order.push("a"); - throw new Error("boom-a"); - }); - world.beforeEvents.chatSend.subscribe(() => { - order.push("b"); - }); - }, - }, - }, - }); - try { - // 命令桥 + 模块两个 = 至少 3 - const n = sb.events.listenerCount("world.beforeEvents.chatSend"); - assert.ok(n >= 3, `expected >=3 listeners, got ${n}`); - - sb.events.emit("world.beforeEvents.chatSend", { - message: "hi", - sender: null, - cancel: false, - }); - assert.deepEqual(order, ["a", "b"]); - const meta = sb.events.lastMeta(); - assert.ok(meta); - assert.equal(meta.listeners, n); - assert.ok(meta.errors.some((e) => e.message.includes("boom-a"))); - } finally { - await sb.dispose(); - } -}); - -test("events.subscribedPaths 列出已 subscribe 的 path", async () => { - const sb = await createSandbox({ - module: { - id: "sub-paths-module", - afterWorldLoad: false, - lifecycle: { - registerEvents() { - world.afterEvents.playerJoin.subscribe(() => {}); - world.afterEvents.playerSpawn.subscribe(() => {}); - }, - }, - }, - }); - try { - const subs = sb.events.subscribedPaths(); - const paths = subs.map((s) => s.path); - assert.ok(paths.includes("world.afterEvents.playerJoin")); - assert.ok(paths.includes("world.afterEvents.playerSpawn")); - // 宿主 chat→命令桥 - assert.ok(paths.includes("world.beforeEvents.chatSend")); - const join = subs.find((s) => s.path === "world.afterEvents.playerJoin"); - assert.equal(join?.listeners, 1); - } finally { - await sb.dispose(); - } -}); - -test("beforeEvents emit 同一事件对象可被回调 cancel", async () => { - const sb = await createSandbox({ boot: false }); - try { - sb.world.beforeEvents.chatSend.subscribe((ev) => { - ev.cancel = true; - }); - const bag = { message: "x", sender: null, cancel: false }; - sb.events.emit("world.beforeEvents.chatSend", bag); - assert.equal(bag.cancel, true); - } finally { - await sb.dispose(); - } -}); diff --git a/_test_archive/sdk/export-coverage.test.mjs b/_test_archive/sdk/export-coverage.test.mjs deleted file mode 100644 index 153599c0..00000000 --- a/_test_archive/sdk/export-coverage.test.mjs +++ /dev/null @@ -1,48 +0,0 @@ -// @ts-check -/** - * export-coverage.test.mjs — L0 声明面覆盖率门禁(≥95%) - */ -import assert from "node:assert/strict"; -import fs from "node:fs"; -import path from "node:path"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; -import { createRequire } from "node:module"; -import { listValueExports, loadOverridesExportNames } from "./scripts/gen-mc-fake.mjs"; - -const require = createRequire(import.meta.url); -const here = path.dirname(fileURLToPath(import.meta.url)); -const THRESHOLD = 0.95; -const overrides = loadOverridesExportNames(); - -function assertCoverage(moduleName, dtsPath, metaPath, overrideSet) { - const source = fs.readFileSync(dtsPath, "utf8"); - const all = listValueExports(source); - assert.ok(fs.existsSync(metaPath), `请先运行 npm run gen:mc-fake(缺 ${metaPath})`); - const meta = JSON.parse(fs.readFileSync(metaPath, "utf8")); - const generated = new Set(meta.generatedNames); - const covered = all.filter((e) => overrideSet.has(e.name) || generated.has(e.name)); - const ratio = covered.length / all.length; - assert.ok( - ratio >= THRESHOLD, - `${moduleName} 覆盖率 ${(ratio * 100).toFixed(1)}% < ${THRESHOLD * 100}%(${covered.length}/${all.length})` - ); -} - -test("L0 覆盖率:@minecraft/server overrides+生成 ≥ 95%", () => { - assertCoverage( - "@minecraft/server", - require.resolve("@minecraft/server/index.d.ts"), - path.join(here, "src/testing/engine/generated/export-names.json"), - overrides.server - ); -}); - -test("L0 覆盖率:@minecraft/server-ui overrides+生成 ≥ 95%", () => { - assertCoverage( - "@minecraft/server-ui", - require.resolve("@minecraft/server-ui/index.d.ts"), - path.join(here, "src/testing/engine/generated/export-names-ui.json"), - overrides.serverUi - ); -}); diff --git a/_test_archive/sdk/module-log-bridge.test.mjs b/_test_archive/sdk/module-log-bridge.test.mjs deleted file mode 100644 index 99d9f33d..00000000 --- a/_test_archive/sdk/module-log-bridge.test.mjs +++ /dev/null @@ -1,33 +0,0 @@ -/** - * module-log-bridge 冒烟(源文件 + strip-types,不经 testing/index) - */ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { - installModuleLogBridge, - shouldForwardModuleLog, -} from "./src/testing/module-log-bridge.ts"; - -test("shouldForwardModuleLog skips host prefixes", () => { - assert.equal(shouldForwardModuleLog("[playground] started"), false); - assert.equal(shouldForwardModuleLog("[objects] create Player"), false); - assert.equal(shouldForwardModuleLog("hello from module"), true); -}); - -test("installModuleLogBridge notifies with module source", () => { - const got = []; - const handle = installModuleLogBridge("demo-mod", (p) => { - got.push({ source: p.source, text: p.text, level: p.level }); - }); - try { - console.log("module says hi"); - assert.equal(got.length, 1); - assert.equal(got[0].source, "demo-mod"); - assert.equal(got[0].text, "module says hi"); - assert.equal(got[0].level, "info"); - console.log("[playground] should skip"); - assert.equal(got.length, 1); - } finally { - handle.dispose(); - } -}); diff --git a/_test_archive/sdk/testing-fixture.test.mjs b/_test_archive/sdk/testing-fixture.test.mjs deleted file mode 100644 index 9bcbaedc..00000000 --- a/_test_archive/sdk/testing-fixture.test.mjs +++ /dev/null @@ -1,105 +0,0 @@ -/** - * 夹具:MemoryDataAdapter 可变 + applyFixtureIntent → ConfigManager - */ -import assert from "node:assert/strict"; -import test from "node:test"; -import { ConfigManager } from "@sfmc-bds/sdk/module-loader"; -import { - applyFixtureIntent, - configsFromFixtureIntent, - createMemoryDataAdapter, - createSandbox, - FIXTURE_PERMISSION_LEVELS, -} from "./dist/esm/testing/index.js"; - -test("MemoryDataAdapter patch + ConfigManager.loadAll", async () => { - ConfigManager.resetForTesting(); - const adapter = createMemoryDataAdapter({ - modules: [], - settings: { a: 1 }, - permissions: [], - module_tokens: {}, - }); - ConfigManager.bindDataAdapter(adapter); - await ConfigManager.init(); - assert.equal(ConfigManager.getSetting("a"), 1); - - adapter.patch({ settings: { b: "x" } }); - await ConfigManager.loadAll(); - assert.equal(ConfigManager.getSetting("a"), 1); - assert.equal(ConfigManager.getSetting("b"), "x"); - ConfigManager.resetForTesting(); -}); - -test("createSandbox fixture settings + permissions", async () => { - const sb = await createSandbox({ - fixture: { - settings: { land_price: 42 }, - permissions: [{ player_name: "alice", level: FIXTURE_PERMISSION_LEVELS.OP }], - }, - }); - try { - assert.equal(ConfigManager.getSetting("land_price"), 42); - const perms = ConfigManager.getPermissions(); - assert.equal(perms.alice, FIXTURE_PERMISSION_LEVELS.OP); - - await sb.applyFixture({ - settings: { land_price: 99 }, - treatPlayersAsOp: true, - }); - assert.equal(ConfigManager.getSetting("land_price"), 99); - - const p = sb.addPlayer({ name: "bob", op: false }); - await sb.applyFixture({ treatPlayersAsOp: true }); - assert.equal(ConfigManager.getPermissions().bob, FIXTURE_PERMISSION_LEVELS.OP); - assert.equal(p.playerPermissionLevel, 2); - - sb.clearDb(); - assert.equal(sb.db.calls.length, 0); - } finally { - await sb.dispose(); - } -}); - -test("configsFromFixtureIntent merges settings/enabled", () => { - const base = { - modules: [{ id: "feature-demo", configKey: "demo", enabled: true, installed: true }], - settings: { keep: 1 }, - permissions: [], - module_tokens: {}, - }; - const out = configsFromFixtureIntent( - base, - { settings: { land_price: 7 }, enabled: false }, - "feature-demo" - ); - assert.equal(out.settings?.keep, 1); - assert.equal(out.settings?.land_price, 7); - assert.equal(out.modules?.[0]?.enabled, false); -}); - -test("applyFixtureIntent clearDb", async () => { - const sb = await createSandbox({}); - try { - await sb.db.tx(async (tx) => { - try { - await tx.call("missing", {}); - } catch { - /* expected */ - } - }); - assert.ok(sb.db.calls.length >= 1); - await applyFixtureIntent( - { - adapter: sb.configAdapter, - db: sb.db, - moduleId: null, - getPlayers: () => [], - }, - { clearDb: true } - ); - assert.equal(sb.db.calls.length, 0); - } finally { - await sb.dispose(); - } -}); diff --git a/_test_archive/sdk/testing-objects.test.mjs b/_test_archive/sdk/testing-objects.test.mjs deleted file mode 100644 index 05fc6520..00000000 --- a/_test_archive/sdk/testing-objects.test.mjs +++ /dev/null @@ -1,145 +0,0 @@ -/** - * testing-objects.test.mjs — sb.objects / sb.events 1:1 API 冒烟(含 Event 对象) - */ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { createSandbox, PLAYGROUND_META } from "./dist/esm/testing/index.js"; - -test("PLAYGROUND_META 含 Player、Event 类与 eventTypes", () => { - assert.ok(PLAYGROUND_META.classes.Player); - assert.equal(PLAYGROUND_META.events["world.afterEvents"].length, 65); - assert.equal(PLAYGROUND_META.events["world.beforeEvents"].length, 16); - assert.equal(PLAYGROUND_META.classes.PlayerJoinAfterEvent?.kind, "event"); - assert.equal( - PLAYGROUND_META.eventTypes["world.afterEvents.playerJoin"].eventType, - "PlayerJoinAfterEvent" - ); - assert.ok(Object.keys(PLAYGROUND_META.eventTypes).length >= 85); -}); - -test("场景天生登记 World / Dimension;Scoreboard 须 create", async () => { - const sb = await createSandbox({}); - try { - const scene = sb.objects.sceneNodes(); - assert.equal(scene.world.id, "world"); - assert.equal(scene.scoreboard, undefined); - assert.equal(scene.dimensions.length, 3); - assert.ok(scene.dimensions.some((d) => d.dimensionId.includes("overworld"))); - assert.ok(scene.dimensions.some((d) => d.dimensionId.includes("nether"))); - assert.ok(scene.dimensions.some((d) => d.dimensionId.includes("the_end"))); - const insp = sb.objects.inspect("world"); - assert.equal(insp.kind, "World"); - assert.equal(insp.props.seed, "sfmc-testing"); - assert.equal(insp.props.allowCheats, true); - assert.equal(insp.props.isHardcore, false); - for (const d of scene.dimensions) { - const dimInsp = sb.objects.inspect(d.id); - assert.equal(dimInsp.kind, "Dimension"); - assert.equal(dimInsp.props.id, d.dimensionId); - } - assert.throws(() => sb.objects.create("World", {}), /天生已有/); - assert.throws(() => sb.objects.create("Dimension", {}), /天生已有/); - - const board = sb.objects.create("Scoreboard", {}); - assert.equal(board.kind, "Scoreboard"); - assert.equal(board.id, "scoreboard"); - assert.equal(board.target, sb.world.scoreboard); - const again = sb.objects.create("Scoreboard", { id: "other" }); - assert.equal(again.id, board.id); - const scene2 = sb.objects.sceneNodes(); - assert.equal(scene2.scoreboard?.id, "scoreboard"); - const sbInsp = sb.objects.inspect("scoreboard"); - assert.equal(sbInsp.kind, "Scoreboard"); - } finally { - await sb.dispose(); - } -}); - -test("PLAYGROUND_META Player 含 Entity 继承成员与方法参数", () => { - assert.ok(PLAYGROUND_META.classes.Player); - assert.equal(PLAYGROUND_META.classes.Player.extends, "Entity"); - assert.ok(PLAYGROUND_META.classes.Player.properties.some((p) => p.name === "location")); - assert.ok(PLAYGROUND_META.classes.Player.properties.some((p) => p.name === "nameTag")); - const teleport = PLAYGROUND_META.classes.Player.methods.find((m) => m.name === "teleport"); - assert.ok(teleport); - assert.ok(teleport.parameters?.some((p) => p.name === "location")); - assert.ok(PLAYGROUND_META.classes.Scoreboard); - assert.ok(PLAYGROUND_META.classes.ScoreboardObjective); -}); - -test("PLAYGROUND_META:l2-skip 标 skip;addEffect 为 l2", () => { - const getPing = PLAYGROUND_META.classes.Player.methods.find((m) => m.name === "getPing"); - assert.equal(getPing?.impl, "skip"); - const isFlying = PLAYGROUND_META.classes.Player.properties.find((p) => p.name === "isFlying"); - assert.equal(isFlying?.impl, "skip"); - const addEffect = PLAYGROUND_META.classes.Entity.methods.find((m) => m.name === "addEffect"); - assert.equal(addEffect?.impl, "l2"); - assert.equal( - PLAYGROUND_META.classes.Player.methods.find((m) => m.name === "addEffect")?.impl, - "l2" - ); -}); - -test("objects.create Player 后可 inspect / $ref", async () => { - const sb = await createSandbox({}); - try { - const h = sb.objects.create("Player", { id: "player-alice", name: "alice", op: true }); - assert.equal(h.id, "player-alice"); - const insp = sb.objects.inspect("player-alice"); - assert.equal(insp.kind, "Player"); - assert.equal(insp.props.name, "alice"); - const scene = sb.objects.sceneNodes(); - assert.ok(scene.players.some((p) => p.id === "player-alice" && p.name === "alice")); - } finally { - await sb.dispose(); - } -}); - -test("objects.create Player / ItemStack / Event / $ref", async () => { - const sb = await createSandbox({}); - try { - const h = sb.objects.create("Player", { name: "alice", op: true }); - assert.equal(h.kind, "Player"); - sb.objects.call(h.id, "sendMessage", ["hi"]); - const player = /** @type {{ log: string[] }} */ (h.target); - assert.ok(player.log.some((l) => l.includes("hi"))); - - const item = sb.objects.create("ItemStack", { typeId: "minecraft:apple", amount: 3 }); - assert.equal(/** @type {{ amount: number }} */ (item.target).amount, 3); - - const ev = sb.objects.create("ChatSendBeforeEvent", { - cancel: false, - message: "hello", - sender: { $ref: h.id }, - }); - assert.equal(ev.kind, "ChatSendBeforeEvent"); - const bag = /** @type {{ message: string, sender: unknown }} */ (ev.target); - assert.equal(bag.message, "hello"); - assert.equal(bag.sender, h.target); - } finally { - await sb.dispose(); - } -}); - -test("events.emit 带 Event 类型 payload 与全路径", async () => { - const sb = await createSandbox({}); - try { - assert.equal(sb.events.eventType("world.afterEvents.playerJoin"), "PlayerJoinAfterEvent"); - let got = null; - sb.world.afterEvents.playerJoin.subscribe((e) => { - got = e; - }); - const payload = sb.objects.create("PlayerJoinAfterEvent", { - playerName: "x", - playerId: "1", - }); - sb.events.emit("world.afterEvents.playerJoin", payload.target); - assert.deepEqual(got, { playerName: "x", playerId: "1" }); - - for (const path of sb.events.paths()) { - sb.events.emit(path, {}); - } - } finally { - await sb.dispose(); - } -}); diff --git a/_test_archive/sdk/testing.test.mjs b/_test_archive/sdk/testing.test.mjs deleted file mode 100644 index b9a84511..00000000 --- a/_test_archive/sdk/testing.test.mjs +++ /dev/null @@ -1,1295 +0,0 @@ -/** - * testing.test.mjs — harness 自身冒烟(配合 minecraft-loader) - */ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { - createFakePlayer, - createFakeWorld, - createFakeDb, - assertMsg, - runLifecycle, - runCleanup, - createSandbox, -} from "./dist/esm/testing/index.js"; - -test("createFakePlayer 收集 sendMessage", () => { - const p = createFakePlayer({ id: "1", name: "tester" }); - p.sendMessage("hello"); - p.sendMessage("§a[√] ok"); - assert.equal(p.log.length, 2); - assert.equal(assertMsg(p, "ok"), true); -}); - -test("createFakeWorld afterEvents 订阅", () => { - const w = createFakeWorld(); - const calls = []; - const handler = (payload) => calls.push(payload); - w.afterEvents.chatSend.subscribe(handler); - w.afterEvents.chatSend.emit({ message: 42 }); - w.afterEvents.chatSend.emit({ message: "bar" }); - w.afterEvents.chatSend.unsubscribe(handler); - w.afterEvents.chatSend.emit({ message: "baz" }); - assert.deepEqual( - calls.map((c) => c.message), - [42, "bar"] - ); -}); - -test("createFakeWorld reset 清空玩家与事件", () => { - const w = createFakeWorld(); - const calls = []; - w.afterEvents.chatSend.subscribe((p) => calls.push(p)); - w.afterEvents.chatSend.emit({ message: 1 }); - w.reset(); - w.afterEvents.chatSend.emit({ message: 2 }); - assert.equal(calls.length, 1); -}); - -test("createFakeDb stub service 调用记录", async () => { - const db = createFakeDb({ - provides: { - "echo.input": (input) => ({ echoed: input }), - }, - }); - const result = await db.tx(async (tx) => { - return tx.call("echo.input", { v: 1 }); - }); - assert.deepEqual(result, { echoed: { v: 1 } }); -}); - -test("createFakeDb 未 stub 服务抛错", async () => { - const db = createFakeDb(); - await assert.rejects( - db.tx(async (tx) => tx.call("missing", {})), - /no stub for service "missing"/ - ); -}); - -test("runLifecycle 跑过 register* → init", async () => { - const order = []; - const descriptor = { - id: "test", - afterWorldLoad: false, - lifecycle: { - registerPermissions() { - order.push("p"); - }, - registerCommands() { - order.push("c"); - }, - registerEvents() { - order.push("e"); - }, - async init() { - await Promise.resolve(); - order.push("i"); - }, - }, - }; - const r = await runLifecycle(descriptor); - assert.equal(r.ok, true); - assert.deepEqual(order, ["p", "c", "e", "i"]); -}); - -test("runLifecycle afterWorldLoad=true 不主动 init", async () => { - const order = []; - const descriptor = { - id: "test-w", - afterWorldLoad: true, - lifecycle: { - registerPermissions() { - order.push("p"); - }, - async init() { - order.push("i"); - }, - }, - }; - await runLifecycle(descriptor, { afterWorldLoad: false }); - assert.deepEqual(order, ["p"]); -}); - -test("runLifecycle 抛错时返回 ok=false", async () => { - const descriptor = { - id: "boom", - afterWorldLoad: false, - lifecycle: { - registerPermissions() { - throw new Error("intentional"); - }, - }, - }; - const r = await runLifecycle(descriptor); - assert.equal(r.ok, false); - assert.match(r.error.message, /intentional/); -}); - -test("runCleanup 跑 cleanup 钩子", async () => { - let cleaned = false; - const descriptor = { - id: "c", - afterWorldLoad: false, - lifecycle: { - cleanup() { - cleaned = true; - }, - }, - }; - const r = await runCleanup(descriptor); - assert.equal(r.ok, true); - assert.equal(cleaned, true); -}); - -test("createSandbox addPlayer", async () => { - const sb = await createSandbox(); - const p = sb.addPlayer({ name: "x" }); - assert.equal(sb.world.getPlayers().length, 1); - assert.equal(p.name, "x"); - assert.ok(sb.supported.l0.server.totalValueExports >= 95); - assert.ok(sb.supported.l0.serverUi.totalValueExports >= 1); - await sb.dispose(); -}); - -test("createSandbox 宿主:afterWorldLoad 经假 worldLoad 后 init", async () => { - const order = []; - const descriptor = { - id: "feature-host-awl", - afterWorldLoad: true, - lifecycle: { - registerPermissions() { - order.push("p"); - }, - registerCommands() { - order.push("c"); - }, - async init() { - order.push("i"); - }, - cleanup() {}, - }, - }; - const sb = await createSandbox({ module: descriptor }); - assert.deepEqual(order, ["p", "c", "i"]); - await sb.dispose(); -}); - -test("createSandbox enabled=false 不 boot", async () => { - let cmds = 0; - const descriptor = { - id: "feature-off", - afterWorldLoad: false, - lifecycle: { - registerCommands() { - cmds++; - }, - init() {}, - cleanup() {}, - }, - }; - const sb = await createSandbox({ module: descriptor, enabled: false }); - assert.equal(cmds, 0); - await sb.dispose(); -}); - -test("createSandbox 宿主:非 afterWorldLoad 在 bootAll 中 init", async () => { - const order = []; - const descriptor = { - id: "feature-host-eager", - afterWorldLoad: false, - lifecycle: { - registerPermissions() { - order.push("p"); - }, - registerCommands() { - order.push("c"); - }, - registerEvents() { - order.push("e"); - }, - init() { - order.push("i"); - }, - cleanup() {}, - }, - }; - const sb = await createSandbox({ module: descriptor }); - assert.deepEqual(order, ["p", "c", "e", "i"]); - await sb.dispose(); -}); - -test("FakePlayer 字段对齐:typeId / nameTag / dimension", async () => { - const sb = await createSandbox(); - const p = sb.addPlayer({ name: "Dana", op: true }); - assert.equal(p.typeId, "minecraft:player"); - assert.equal(p.nameTag, "Dana"); - assert.equal(p.dimension.id, "minecraft:overworld"); - assert.equal(p.playerPermissionLevel, 2); - await sb.dispose(); -}); - -test("sb.emit 触发 playerJoin / playerSpawn / chatSend / scriptEvent", async () => { - const sb = await createSandbox(); - const p = sb.addPlayer({ name: "Eve" }); - const joins = []; - const spawns = []; - const chats = []; - const scripts = []; - sb.world.afterEvents.playerJoin.subscribe((ev) => joins.push(ev.playerName)); - sb.world.afterEvents.playerSpawn.subscribe((ev) => spawns.push(ev.initialSpawn)); - sb.world.beforeEvents.chatSend.subscribe((ev) => chats.push(ev.message)); - sb.system.afterEvents.scriptEventReceive.subscribe((ev) => scripts.push(ev.id)); - - sb.emit.playerJoin(p); - sb.emit.playerSpawn(p, { initialSpawn: false }); - sb.emit.chatSend(p, "!hi"); - sb.emit.scriptEvent("sfmc:ping"); - - assert.deepEqual(joins, ["Eve"]); - assert.deepEqual(spawns, [false]); - assert.deepEqual(chats, ["!hi"]); - assert.deepEqual(scripts, ["sfmc:ping"]); - await sb.dispose(); -}); - -test("Scoreboard:Learn 样例路径 setScore / getScore ?? 0", async () => { - const sb = await createSandbox(); - const p = sb.addPlayer({ name: "Fran" }); - const board = sb.world.scoreboard; - let objective = board.getObjective("demo"); - assert.equal(objective, undefined); - objective = board.addObjective("demo", "Demo"); - assert.equal(objective.displayName, "Demo"); - assert.equal(objective.isValid, true); - - assert.equal(objective.getScore(p.scoreboardIdentity), undefined); - objective.setScore(p.scoreboardIdentity, 100); - const playerScore = objective.getScore(p.scoreboardIdentity) ?? 0; - objective.setScore(p.scoreboardIdentity, playerScore + 10); - assert.equal(objective.getScore(p), 110); - assert.equal(objective.addScore(p.name, 5), 115); - assert.equal(objective.hasParticipant(p), true); - - board.setObjectiveAtDisplaySlot("Sidebar", { - objective, - sortOrder: 1, - }); - assert.equal(board.getObjectiveAtDisplaySlot("Sidebar")?.objective.id, "demo"); - assert.equal(board.getObjectives().length, 1); - - assert.equal(board.removeObjective("demo"), true); - assert.equal(objective.isValid, false); - assert.equal(board.getObjective("demo"), undefined); - await sb.dispose(); -}); - -test("Scoreboard:重复 addObjective 抛错;未设分 getScore 为 undefined", async () => { - const sb = await createSandbox(); - const board = sb.world.scoreboard; - board.addObjective("money"); - assert.throws(() => board.addObjective("money"), /already exists/); - const obj = board.getObjective("money"); - assert.equal(obj.getScore("nobody"), undefined); - await sb.dispose(); -}); - -test("Dimension:getBlock 缺省空气;setBlockPermutation 可读写", async () => { - const { BlockPermutation } = await import("@minecraft/server"); - const sb = await createSandbox(); - const dim = sb.world.getDimension("overworld"); - assert.equal(dim.id, "minecraft:overworld"); - const air = dim.getBlock({ x: 1.9, y: 64.2, z: -2.1 }); - assert.equal(air.isAir, true); - assert.equal(air.typeId, "minecraft:air"); - assert.equal(air.x, 1); - assert.equal(air.z, -3); - - const stone = BlockPermutation.resolve("minecraft:stone"); - dim.setBlockPermutation({ x: 1, y: 64, z: -3 }, stone); - const again = dim.getBlock({ x: 1, y: 64, z: -3 }); - assert.equal(again.isAir, false); - assert.equal(again.typeId, "minecraft:stone"); - assert.equal(again.permutation.matches("stone"), true); - - const p = sb.addPlayer({ name: "Geo" }); - assert.equal(p.dimension.id, "minecraft:overworld"); - assert.equal(p.dimension.getBlock(p.location).isAir, true); - - assert.throws(() => { - void /** @type {any} */ (dim).fillBlocks; - }, /未实现的 Minecraft API/); - await sb.dispose(); -}); - -test("World 薄 L2:默认三维、时间、sendMessage、getEntity", async () => { - const sb = await createSandbox(); - const ids = ["minecraft:overworld", "minecraft:nether", "minecraft:the_end"].map( - (id) => sb.world.getDimension(id).id - ); - assert.deepEqual(ids, ["minecraft:overworld", "minecraft:nether", "minecraft:the_end"]); - assert.equal(sb.world.getDimension("nether").heightRange.max, 128); - assert.equal(sb.world.seed, "sfmc-testing"); - assert.equal(sb.world.isHardcore, false); - - sb.world.setAbsoluteTime(25000); - assert.equal(sb.world.getAbsoluteTime(), 25000); - assert.equal(sb.world.getDay(), 1); - assert.equal(sb.world.getTimeOfDay(), 1000); - sb.world.setTimeOfDay("Noon"); - assert.equal(sb.world.getTimeOfDay(), 6000); - - sb.world.setDefaultSpawnLocation({ x: 8, y: 70, z: -4 }); - assert.deepEqual(sb.world.getDefaultSpawnLocation(), { x: 8, y: 70, z: -4 }); - - const p = sb.addPlayer({ name: "MsgP" }); - sb.world.sendMessage("hello-world"); - assert.ok(p.log.some((l) => l.includes("hello-world"))); - - const fox = sb.world.getDimension("overworld").spawnEntity("minecraft:fox", { x: 1, y: 64, z: 1 }); - assert.equal(sb.world.getEntity(fox.id)?.typeId, "minecraft:fox"); - assert.equal(sb.world.getEntity(p.id)?.name, "MsgP"); - - sb.world.setDynamicProperty("k", 1); - assert.equal(sb.world.getDynamicProperty("k"), 1); - assert.deepEqual(sb.world.getDynamicPropertyIds(), ["k"]); - await sb.dispose(); -}); - -test("Dimension 部分 L2:spawnItem / getEntitiesAtBlockLocation / 天气 / isChunkLoaded", async () => { - const { ItemStack } = await import("@minecraft/server"); - const sb = await createSandbox(); - const dim = sb.world.getDimension("overworld"); - assert.equal(dim.isChunkLoaded({ x: 1e6, y: 0, z: 1e6 }), true); - assert.equal(dim.getWeather(), "Clear"); - dim.setWeather("Rain"); - assert.equal(dim.getWeather(), "Rain"); - - const item = dim.spawnItem(new ItemStack("minecraft:diamond", 2), { x: 3.2, y: 64, z: -1.8 }); - assert.equal(item.typeId, "minecraft:item"); - const at = dim.getEntitiesAtBlockLocation({ x: 3, y: 64, z: -2 }); - assert.equal(at.length, 1); - assert.equal(at[0].id, item.id); - await sb.dispose(); -}); - -test("L2 Dimension.getBlockFromRay:area/fly 向下落地路径", async () => { - const { BlockPermutation } = await import("@minecraft/server"); - const { UnimplementedMinecraftApiError } = await import("./dist/esm/testing/index.js"); - const sb = await createSandbox(); - const dim = sb.world.getDimension("overworld"); - dim.setBlockPermutation({ x: 2, y: 60, z: -1 }, BlockPermutation.resolve("minecraft:stone")); - - const hit = dim.getBlockFromRay( - { x: 2.3, y: 80, z: -0.7 }, - { x: 0, y: -1, z: 0 }, - { includeLiquidBlocks: true, includePassableBlocks: false } - ); - assert.ok(hit); - assert.equal(hit.block.typeId, "minecraft:stone"); - assert.equal(hit.block.x, 2); - assert.equal(hit.block.y, 60); - assert.equal(hit.block.z, -1); - assert.equal(hit.face, "Up"); - assert.deepEqual(hit.block.location, { x: 2, y: 60, z: -1 }); - - // fly disableFly:落到方块顶面 - const land = { - x: hit.block.location.x, - y: hit.block.location.y + 1, - z: hit.block.location.z, - }; - assert.deepEqual(land, { x: 2, y: 61, z: -1 }); - - dim.setBlockType({ x: 2, y: 70, z: -1 }, "minecraft:water"); - const skipWater = dim.getBlockFromRay( - { x: 2.1, y: 80, z: -0.9 }, - { x: 0, y: -1, z: 0 }, - { includeLiquidBlocks: false } - ); - assert.equal(skipWater?.block.y, 60); - const stopWater = dim.getBlockFromRay( - { x: 2.1, y: 80, z: -0.9 }, - { x: 0, y: -1, z: 0 }, - { includeLiquidBlocks: true } - ); - assert.equal(stopWater?.block.typeId, "minecraft:water"); - assert.equal(stopWater?.block.y, 70); - - assert.equal( - dim.getBlockFromRay({ x: 0, y: 100, z: 0 }, { x: 0, y: -1, z: 0 }, { maxDistance: 5 }), - undefined - ); - assert.throws(() => dim.getBlockFromRay({ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0 }), /zero/i); - assert.throws( - () => - dim.getBlockFromRay({ x: 0, y: 0, z: 0 }, { x: 0, y: -1, z: 0 }, { - includeTypes: ["minecraft:stone"], - }), - (err) => - err instanceof UnimplementedMinecraftApiError || - (err instanceof Error && /未实现的 Minecraft API/.test(err.message)) - ); - await sb.dispose(); -}); - -test("Entity:spawnEntity / getEntities / remove / teleport", async () => { - const sb = await createSandbox(); - const dim = sb.world.getDimension("overworld"); - const spawned = []; - const died = []; - sb.world.afterEvents.entitySpawn.subscribe((ev) => spawned.push(ev.entity.typeId)); - sb.world.afterEvents.entityDie.subscribe((ev) => died.push(ev.deadEntity.typeId)); - - const fox = dim.spawnEntity("minecraft:fox", { x: 0, y: 64, z: 0 }); - assert.equal(fox.typeId, "minecraft:fox"); - assert.equal(fox.isValid, true); - assert.equal(fox.scoreboardIdentity.type, "Entity"); - assert.deepEqual(spawned, ["minecraft:fox"]); - - fox.addTag("pet"); - dim.spawnEntity("minecraft:cow", { x: 10, y: 64, z: 0 }); - const pets = dim.getEntities({ tags: ["pet"] }); - assert.equal(pets.length, 1); - assert.equal(pets[0].id, fox.id); - - const near = dim.getEntities({ - location: { x: 0, y: 64, z: 0 }, - maxDistance: 2, - closest: 1, - }); - assert.equal(near[0].typeId, "minecraft:fox"); - - fox.teleport({ x: 5, y: 70, z: 5 }); - assert.equal(fox.location.y, 70); - - assert.equal(fox.kill(), true); - assert.equal(fox.isValid, false); - assert.deepEqual(died, ["minecraft:fox"]); - assert.equal(dim.getEntities().length, 1); - - const horse = dim.spawnEntity("minecraft:horse", { - x: 1, - y: 64, - z: 1, - }); - assert.equal(horse.typeId, "minecraft:horse"); - await sb.dispose(); -}); - -test("Inventory:ItemStack + Container Learn 样例路径", async () => { - const { ItemStack, EntityInventoryComponent, EntityComponentTypes } = await import( - "@minecraft/server" - ); - const sb = await createSandbox(); - const player = sb.addPlayer({ name: "Inv" }); - const inv = player.getComponent(EntityComponentTypes.Inventory); - assert.ok(inv); - assert.equal(EntityInventoryComponent.componentId, "minecraft:inventory"); - assert.equal(inv.inventorySize, 36); - assert.equal(inv.container.size, 36); - - inv.container.setItem(0, new ItemStack("minecraft:apple", 10)); - assert.equal(inv.container.getItem(0)?.typeId, "minecraft:apple"); - assert.equal(inv.container.getItem(0)?.amount, 10); - assert.equal(inv.container.emptySlotsCount, 35); - - const cart = sb.world.getDimension("overworld").spawnEntity("minecraft:chest_minecart", { - x: 0, - y: 64, - z: 0, - }); - const cartInv = cart.getComponent("minecraft:inventory"); - assert.equal(cartInv.container.size, 27); - inv.container.transferItem(0, cartInv.container); - assert.equal(inv.container.getItem(0), undefined); - assert.equal(cartInv.container.getItem(0)?.typeId, "minecraft:apple"); - - cartInv.container.setItem(1, new ItemStack("emerald", 10)); - inv.container.setItem(0, new ItemStack("minecraft:cake", 1)); - cartInv.container.swapItems(1, 0, inv.container); - assert.equal(inv.container.getItem(0)?.typeId, "minecraft:emerald"); - assert.equal(cartInv.container.getItem(1)?.typeId, "minecraft:cake"); - - const stack = new ItemStack("minecraft:stick", 1); - assert.throws(() => { - void /** @type {any} */ (stack).definitelyNotAMethod; - }, /未实现的 Minecraft API.*ItemStack/); - assert.throws(() => { - void /** @type {any} */ (inv.container).definitelyNotAMethod; - }, /未实现的 Minecraft API.*Container/); - await sb.dispose(); -}); - -test("ItemStack / Container 缺成员硬失败(独立)", async () => { - const { ItemStack } = await import("@minecraft/server"); - const sb = await createSandbox(); - const item = new ItemStack("minecraft:dirt", 2); - assert.equal(item.typeId, "minecraft:dirt"); - assert.equal(item.clone().amount, 2); - assert.throws(() => { - void /** @type {any} */ (item).getLore; - }, /UnimplementedMinecraftApiError|未实现的 Minecraft API/); - const bag = sb.addPlayer({ name: "HardFail" }).getComponent("minecraft:inventory"); - assert.throws(() => { - void /** @type {any} */ (bag.container).getItemCooldown; - }, /未实现的 Minecraft API.*Container/); - await sb.dispose(); -}); - -test("server-ui:CustomForm / MessageBox / Observables(本地 pin 全表面)", async () => { - const { - CustomForm, - MessageBox, - ObservableBoolean, - ObservableNumber, - DataDrivenScreenClosedReason, - uiManager, - FormRejectReason, - } = await import("@minecraft/server-ui"); - const sb = await createSandbox(); - const player = sb.addPlayer({ name: "Ui" }); - - // L0:未手写类型仍可 import - assert.equal(typeof FormRejectReason, "object"); - - const toggled = new ObservableBoolean(false); - const idx = new ObservableNumber(0); - let clicked = 0; - const form = new CustomForm(player, "Title") - .label("hello") - .toggle("on", toggled) - .button("Go", () => { - clicked++; - }); - sb.ui.queueResponse(player, { selection: 0 }); - const reason = await form.show(); - assert.equal(clicked, 1); - assert.equal(reason, DataDrivenScreenClosedReason.ClientClosed); - assert.equal(idx.value, 0); - - const box = new MessageBox(player, "Ask").body("?").button1("Yes").button2("No"); - sb.ui.queueResponse(player, { selection: 1, closeReason: DataDrivenScreenClosedReason.ClientClosed }); - const boxRes = await box.show(); - assert.equal(boxRes.selection, 1); - assert.equal(boxRes.closeReason, DataDrivenScreenClosedReason.ClientClosed); - - sb.ui.queueResponse(player, { selection: 0 }); - uiManager.closeAllForms(player); - const empty = await new MessageBox(player, "x").show(); - assert.equal(empty.closeReason, DataDrivenScreenClosedReason.ClientClosed); - await sb.dispose(); -}); - -test("sb.emit.chatSend !命令 走 Command.trigger", async () => { - const { Command, Msg, Permission } = await import("./dist/esm/sapi/runtime/index.js"); - const descriptor = { - id: "feature-chat-cmd", - afterWorldLoad: false, - lifecycle: { - registerPermissions() { - Permission.register("chatcmd.use", Permission.Any); - }, - registerCommands() { - Command.register( - "chatping", - "chatcmd.use", - (player) => { - if (player) Msg.info("chat-pong", player); - }, - "chat ping" - ); - }, - cleanup() {}, - }, - }; - const sb = await createSandbox({ module: descriptor }); - const player = sb.addPlayer({ name: "ChatCmd", op: true }); - sb.emit.chatSend(player, "!chatping"); - sb.flush(); - await Promise.resolve(); - sb.flush(); - assert.equal(assertMsg(player, "chat-pong"), true); - await sb.dispose(); -}); - -test("Permission/Command/ModuleRegistry 已装载只读快照", async () => { - const { Command, Permission } = await import("./dist/esm/sapi/runtime/index.js"); - const { ModuleRegistry } = await import("./dist/esm/module-loader/index.js"); - const descriptor = { - id: "feature-inventory-snap", - afterWorldLoad: false, - lifecycle: { - registerPermissions() { - Permission.register("inv.use", Permission.Member); - }, - registerCommands() { - Command.register("invping", "inv.use", () => undefined, "inventory ping", "feature-inventory-snap"); - }, - registerEvents() {}, - cleanup() {}, - }, - }; - const sb = await createSandbox({ module: descriptor }); - const phase = ModuleRegistry.getBootPhase(); - assert.equal(phase.startup, true); - assert.equal(phase.worldLoad, true); - assert.match(phase.summary, /已 startup/); - assert.match(phase.summary, /已 worldLoad/); - assert.equal(ModuleRegistry.isWorldLoaded(), true); - assert.equal(ModuleRegistry.isBooted("feature-inventory-snap"), true); - - const cmds = Command.entries().filter((e) => e.name === "invping"); - assert.equal(cmds.length, 1); - assert.equal(cmds[0].moduleId, "feature-inventory-snap"); - assert.equal(cmds[0].description, "inventory ping"); - - const perms = Permission.entries().filter((e) => e.name === "inv.use"); - assert.equal(perms.length, 1); - assert.equal(perms[0].level, Permission.Member); - await sb.dispose(); -}); - -test("createSandbox moduleRoot 装载真实入口", async () => { - const fs = await import("node:fs"); - const os = await import("node:os"); - const path = await import("node:path"); - const { pathToFileURL } = await import("node:url"); - const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-mod-")); - fs.mkdirSync(path.join(root, "sapi", "src"), { recursive: true }); - fs.writeFileSync( - path.join(root, "package.json"), - JSON.stringify({ name: "tmp-mod", type: "module" }) - ); - fs.writeFileSync( - path.join(root, "sapi", "manifest.json"), - JSON.stringify({ schemaVersion: 2, id: "feature-load-root", name: "Load Root" }) - ); - // 用 .mjs 避免测试依赖 tsx;生产入口仍是 sapi/src/index.ts - const entry = path.join(root, "sapi", "src", "index.mjs"); - const sdkLoader = pathToFileURL(path.resolve("dist/esm/module-loader/index.js")).href; - const sdkRuntime = pathToFileURL(path.resolve("dist/esm/sapi/runtime/index.js")).href; - fs.writeFileSync( - entry, - ` -import { ModuleRegistry } from ${JSON.stringify(sdkLoader)}; -import { Command, Msg, Permission } from ${JSON.stringify(sdkRuntime)}; - -export const DESCRIPTOR = { - id: "feature-load-root", - afterWorldLoad: false, - lifecycle: { - registerPermissions() { Permission.register("loadroot.use", Permission.Any); }, - registerCommands() { - Command.register("loadroot", "loadroot.use", (player) => { - if (player) Msg.info("loaded-from-root", player); - }, "load root"); - }, - registerEvents() {}, - init() {}, - cleanup() {}, - }, -}; -ModuleRegistry.register(DESCRIPTOR); -` - ); - - const sb = await createSandbox({ moduleRoot: root }); - assert.equal(sb.module?.id, "feature-load-root"); - assert.equal(sb.module?.root, path.resolve(root)); - const player = sb.addPlayer({ name: "Root", op: true }); - sb.emit.chatSend(player, "!loadroot"); - sb.flush(); - await Promise.resolve(); - sb.flush(); - assert.equal(assertMsg(player, "loaded-from-root"), true); - await sb.dispose(); - fs.rmSync(root, { recursive: true, force: true }); -}); - -test("minecraft-loader 钉 SDK:moduleRoot 下假 node_modules 不劫持 Command", async () => { - const fs = await import("node:fs"); - const os = await import("node:os"); - const path = await import("node:path"); - const { Command, Permission } = await import("./dist/esm/sapi/runtime/index.js"); - - const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-dual-")); - const decoyPkg = path.join(root, "node_modules", "@sfmc-bds", "sdk"); - fs.mkdirSync(path.join(decoyPkg, "dist", "esm", "sapi", "runtime"), { recursive: true }); - fs.mkdirSync(path.join(root, "sapi", "src"), { recursive: true }); - fs.writeFileSync( - path.join(decoyPkg, "package.json"), - JSON.stringify({ - name: "@sfmc-bds/sdk", - version: "0.0.0-decoy", - type: "module", - exports: { - "./sapi/runtime": "./dist/esm/sapi/runtime/index.js", - "./module-loader": "./dist/esm/module-loader/index.js", - }, - }) - ); - fs.mkdirSync(path.join(decoyPkg, "dist", "esm", "module-loader"), { recursive: true }); - // 诱饵:register 写到 decoy 自己的表;若 loader 未钉死,宿主 Command.entries 会为空 - fs.writeFileSync( - path.join(decoyPkg, "dist", "esm", "sapi", "runtime", "index.js"), - ` -const list = {}; -export class Command { - static list = list; - static register(name, permission, callback, description, moduleId) { - list[name] = { permission, callback, description, moduleId }; - globalThis.__sfmcDecoyCmd = true; - return true; - } - static entries() { return Object.keys(list).map((name) => ({ name, ...list[name] })); } - static names() { return Object.keys(list); } - static unregister() { return false; } - static unregisterByModule() { return 0; } - static has() { return false; } - static getModuleId() { return undefined; } - static trigger() {} -} -export class Permission { - static Any = 0; - static registry = new Map(); - static register(name, level) { this.registry.set(name, level); globalThis.__sfmcDecoyPerm = true; } - static entries() { return [...this.registry.entries()].map(([name, level]) => ({ name, level })); } - static clearRegistry() { this.registry.clear(); } - static check() { return true; } -} -export class Msg { static info() {} } -` - ); - fs.writeFileSync( - path.join(decoyPkg, "dist", "esm", "module-loader", "index.js"), - ` -export class ModuleRegistry { - static register() {} -} -` - ); - fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "dual-mod", type: "module" })); - fs.writeFileSync( - path.join(root, "sapi", "manifest.json"), - JSON.stringify({ schemaVersion: 2, id: "feature-dual-sdk", name: "Dual" }) - ); - fs.writeFileSync( - path.join(root, "sapi", "src", "index.mjs"), - ` -import { ModuleRegistry } from "@sfmc-bds/sdk/module-loader"; -import { Command, Msg, Permission } from "@sfmc-bds/sdk/sapi/runtime"; -export const DESCRIPTOR = { - id: "feature-dual-sdk", - afterWorldLoad: false, - lifecycle: { - registerPermissions() { Permission.register("dual.use", Permission.Any); }, - registerCommands() { - Command.register("dualping", "dual.use", (player) => { - if (player) Msg.info("dual-ok", player); - }, "dual ping", "feature-dual-sdk"); - }, - registerEvents() {}, - init() {}, - cleanup() {}, - }, -}; -ModuleRegistry.register(DESCRIPTOR); -` - ); - - globalThis.__sfmcDecoyCmd = false; - globalThis.__sfmcDecoyPerm = false; - const sb = await createSandbox({ moduleRoot: root }); - assert.equal(sb.module?.id, "feature-dual-sdk"); - assert.equal(globalThis.__sfmcDecoyCmd, false, "不得落入诱饵 Command"); - assert.equal(globalThis.__sfmcDecoyPerm, false, "不得落入诱饵 Permission"); - const cmds = Command.entries().filter((e) => e.name === "dualping"); - assert.equal(cmds.length, 1); - assert.equal(cmds[0].moduleId, "feature-dual-sdk"); - const perms = Permission.entries().filter((e) => e.name === "dual.use"); - assert.equal(perms.length, 1); - await sb.dispose(); - fs.rmSync(root, { recursive: true, force: true }); -}); - -test("L2 本批:getGameMode/setGameMode + playerGameModeChange", async () => { - const { createSandbox } = await import("./dist/esm/testing/index.js"); - const { GameMode } = await import("@minecraft/server"); - const sb = await createSandbox({}); - const seen = []; - sb.world.afterEvents.playerGameModeChange.subscribe((ev) => { - seen.push({ from: ev.fromGameMode, to: ev.toGameMode }); - }); - const p = sb.addPlayer({ name: "GM" }); - assert.equal(p.getGameMode(), GameMode.Survival); - p.setGameMode(GameMode.Creative); - assert.equal(p.getGameMode(), "Creative"); - assert.deepEqual(seen, [{ from: "Survival", to: "Creative" }]); - await sb.dispose(); -}); - -test("L2 本批:runCommand 记录 + gamemode 薄解析", async () => { - const { createSandbox } = await import("./dist/esm/testing/index.js"); - const sb = await createSandbox({}); - const p = sb.addPlayer({ name: "Cmd" }); - const r = p.runCommand("give @s minecraft:apple 1"); - assert.equal(r.successCount, 1); - assert.ok(p.commandLog.includes("give @s minecraft:apple 1")); - p.runCommand("gamemode creative"); - assert.equal(p.getGameMode(), "Creative"); - const dim = sb.world.getDimension("overworld"); - dim.runCommand("say hi"); - assert.ok(dim.commandLog.includes("say hi")); - await sb.dispose(); -}); - -test("L2 第二批:runCommand give/clear → 物品栏", async () => { - const { createSandbox } = await import("./dist/esm/testing/index.js"); - const sb = await createSandbox({}); - const p = sb.addPlayer({ name: "Shop" }); - p.runCommand("give @s minecraft:diamond 3 0"); - const inv = p.getComponent("minecraft:inventory").container; - assert.equal(inv.getItem(0)?.typeId, "minecraft:diamond"); - assert.equal(inv.getItem(0)?.amount, 3); - // daily-task 形态:clear "Name" item data qty - p.runCommand('clear "Shop" minecraft:diamond 0 2'); - assert.equal(inv.getItem(0)?.amount, 1); - p.runCommand("clear @s minecraft:diamond 0 1"); - assert.equal(inv.getItem(0), undefined); - // ability 另测;此处仅确认未知命令仍记录 - p.runCommand("title @s title hi"); - assert.ok(p.commandLog.includes("title @s title hi")); - await sb.dispose(); -}); - -test("L2 第四批:runCommand ability → mayfly 状态袋", async () => { - const { createSandbox } = await import("./dist/esm/testing/index.js"); - const sb = await createSandbox({}); - const p = sb.addPlayer({ name: "Flyer" }); - assert.equal(p.abilities.mayfly, false); - p.runCommand("ability @s mayfly true"); - assert.ok(p.commandLog.includes("ability @s mayfly true")); - assert.equal(p.abilities.mayfly, true); - p.runCommand("ability @s mayfly false"); - assert.equal(p.abilities.mayfly, false); - // 非本目标:只记录、不改袋 - p.runCommand("ability Other mayfly true"); - assert.equal(p.abilities.mayfly, false); - await sb.dispose(); -}); - -test("L2 第四批:playerPlaceBlock / playerInteractWithBlock + setBlock", async () => { - const { createSandbox } = await import("./dist/esm/testing/index.js"); - const { BlockPermutation } = await import("@minecraft/server"); - const sb = await createSandbox({}); - const p = sb.addPlayer({ name: "Builder" }); - const loc = { x: 3, y: 64, z: -1 }; - const block = p.dimension.getBlock(loc); - assert.equal(block.isAir, true); - - const beforePlace = []; - const afterPlace = []; - const interacts = []; - sb.world.beforeEvents.playerPlaceBlock.subscribe((ev) => { - beforePlace.push({ - id: ev.permutationToPlace?.type?.id, - cancel: ev.cancel, - bx: ev.block?.x, - }); - if (ev.permutationToPlace?.type?.id === "minecraft:bedrock") ev.cancel = true; - }); - sb.world.afterEvents.playerPlaceBlock.subscribe((ev) => { - afterPlace.push(ev.block?.typeId); - }); - sb.world.afterEvents.playerInteractWithBlock.subscribe((ev) => { - interacts.push({ name: ev.player.name, id: ev.block?.typeId, face: ev.blockFace }); - }); - - const stone = BlockPermutation.resolve("minecraft:stone"); - sb.emit.playerPlaceBlock(p, { block, permutationToPlace: stone }); - assert.equal(beforePlace.length, 1); - assert.equal(beforePlace[0].id, "minecraft:stone"); - assert.equal(p.dimension.getBlock(loc).typeId, "minecraft:stone"); - assert.deepEqual(afterPlace, ["minecraft:stone"]); - - // before cancel:不落块 - const air = p.dimension.getBlock({ x: 4, y: 64, z: -1 }); - sb.emit.playerPlaceBlock(p, { - block: air, - permutationToPlace: BlockPermutation.resolve("minecraft:bedrock"), - }); - assert.equal(air.isAir, true); - assert.equal(afterPlace.length, 1); - - // 仅 before:可断言 cancel,不落块 - const onlyBefore = []; - sb.world.beforeEvents.playerPlaceBlock.subscribe((ev) => onlyBefore.push(ev.cancel)); - sb.emit.playerPlaceBlock(p, { - before: true, - block: p.dimension.getBlock({ x: 5, y: 64, z: 0 }), - permutationToPlace: BlockPermutation.resolve("minecraft:dirt"), - }); - assert.deepEqual(onlyBefore, [false]); - assert.equal(p.dimension.getBlock({ x: 5, y: 64, z: 0 }).isAir, true); - - sb.emit.playerInteractWithBlock(p, { block: p.dimension.getBlock(loc), blockFace: "Up" }); - assert.deepEqual(interacts, [{ name: "Builder", id: "minecraft:stone", face: "Up" }]); - await sb.dispose(); -}); - -test("L2:playerInteractWithEntity emit 薄事件袋", async () => { - const { createSandbox } = await import("./dist/esm/testing/index.js"); - const { ItemStack } = await import("@minecraft/server"); - const sb = await createSandbox({}); - const p = sb.addPlayer({ name: "Interactor" }); - const fox = p.dimension.spawnEntity("minecraft:fox", { x: 2, y: 64, z: 0 }); - const after = []; - const before = []; - sb.world.afterEvents.playerInteractWithEntity.subscribe((ev) => { - after.push({ - name: ev.player.name, - target: ev.target?.typeId, - item: ev.itemStack?.type?.id ?? null, - beforeItem: ev.beforeItemStack?.type?.id ?? null, - }); - }); - sb.world.beforeEvents.playerInteractWithEntity.subscribe((ev) => { - before.push({ name: ev.player.name, target: ev.target?.typeId, cancel: ev.cancel }); - ev.cancel = true; - }); - - const stick = new ItemStack("minecraft:stick", 1); - sb.emit.playerInteractWithEntity(p, fox, { itemStack: stick }); - assert.deepEqual(after, [ - { - name: "Interactor", - target: "minecraft:fox", - item: "minecraft:stick", - beforeItem: "minecraft:stick", - }, - ]); - - sb.emit.playerInteractWithEntity(p, fox, { before: true, itemStack: stick }); - assert.equal(before.length, 1); - assert.equal(before[0].cancel, false); - assert.equal(after.length, 1); - await sb.dispose(); -}); - -test("L2 第四批:entityHurt damageSource 含 damagingEntity", async () => { - const { createSandbox } = await import("./dist/esm/testing/index.js"); - const sb = await createSandbox({}); - const p = sb.addPlayer({ name: "Victim" }); - const fox = p.dimension.spawnEntity("minecraft:fox", { x: 1, y: 64, z: 0 }); - const hurts = []; - const dies = []; - sb.world.afterEvents.entityHurt.subscribe((ev) => { - hurts.push({ - dmg: ev.damage, - cause: ev.damageSource?.cause, - damager: ev.damageSource?.damagingEntity?.typeId, - projectile: ev.damageSource?.damagingProjectile?.typeId ?? null, - }); - }); - sb.world.afterEvents.entityDie.subscribe((ev) => { - dies.push({ - cause: ev.damageSource?.cause, - damager: ev.damageSource?.damagingEntity?.typeId, - }); - }); - fox.applyDamage(5, { - cause: "entityAttack", - damagingEntity: p, - }); - assert.deepEqual(hurts, [ - { dmg: 5, cause: "entityAttack", damager: "minecraft:player", projectile: null }, - ]); - fox.applyDamage(100, { - cause: "projectile", - damagingEntity: p, - damagingProjectile: fox, - }); - assert.equal(fox.isValid, false); - assert.ok(hurts.some((h) => h.cause === "projectile" && h.projectile === "minecraft:fox")); - assert.deepEqual(dies, [{ cause: "projectile", damager: "minecraft:player" }]); - await sb.dispose(); -}); - -test("L2:projectile applyDamage → entityHurt/Die 可断言 Entity projectile", async () => { - const { createSandbox } = await import("./dist/esm/testing/index.js"); - const { EntityDamageCause } = await import("@minecraft/server"); - const sb = await createSandbox({}); - const shooter = sb.addPlayer({ name: "Archer" }); - const victim = shooter.dimension.spawnEntity("minecraft:fox", { x: 2, y: 64, z: 0 }); - const arrow = shooter.dimension.spawnEntity("minecraft:arrow", { x: 1, y: 64, z: 0 }); - - const hurts = []; - const dies = []; - sb.world.afterEvents.entityHurt.subscribe((ev) => { - hurts.push({ - hurt: ev.hurtEntity, - dmg: ev.damage, - cause: ev.damageSource.cause, - damager: ev.damageSource.damagingEntity, - projectile: ev.damageSource.damagingProjectile, - }); - }); - sb.world.afterEvents.entityDie.subscribe((ev) => { - dies.push({ - dead: ev.deadEntity, - cause: ev.damageSource.cause, - damager: ev.damageSource.damagingEntity, - projectile: ev.damageSource.damagingProjectile, - }); - }); - - // EntityApplyDamageByProjectileOptions:无 cause,推断为 projectile;保留 Entity 引用 - assert.equal( - victim.applyDamage(5, { - damagingProjectile: arrow, - damagingEntity: shooter, - }), - true - ); - assert.equal(hurts.length, 1); - assert.equal(hurts[0].cause, EntityDamageCause.projectile); - assert.equal(hurts[0].damager, shooter); - assert.equal(hurts[0].projectile, arrow); - assert.equal(hurts[0].projectile.typeId, "minecraft:arrow"); - assert.equal(hurts[0].hurt, victim); - - // 致死:hurt + die 均带同一 arrow / shooter 引用 - assert.equal( - victim.applyDamage(100, { - damagingProjectile: arrow, - damagingEntity: shooter, - }), - true - ); - assert.equal(victim.isValid, false); - assert.ok(hurts.some((h) => h.cause === "projectile" && h.projectile === arrow && h.dmg >= 15)); - assert.equal(dies.length, 1); - assert.equal(dies[0].cause, "projectile"); - assert.equal(dies[0].damager, shooter); - assert.equal(dies[0].projectile, arrow); - assert.equal(dies[0].dead, victim); - - // 显式 cause + projectile 仍可透传 - const pig = shooter.dimension.spawnEntity("minecraft:pig", { x: 3, y: 64, z: 0 }); - const snowball = shooter.dimension.spawnEntity("minecraft:snowball", { x: 3.5, y: 64, z: 0 }); - const hurt2 = []; - sb.world.afterEvents.entityHurt.subscribe((ev) => { - if (ev.hurtEntity === pig) hurt2.push(ev.damageSource); - }); - pig.applyDamage(3, { - cause: EntityDamageCause.projectile, - damagingEntity: shooter, - damagingProjectile: snowball, - }); - assert.equal(hurt2.length, 1); - assert.equal(hurt2[0].cause, "projectile"); - assert.equal(hurt2[0].damagingProjectile, snowball); - assert.equal(hurt2[0].damagingEntity, shooter); - - await sb.dispose(); -}); - -test("L2 第二批:applyDamage + 生命值袋", async () => { - const { createSandbox } = await import("./dist/esm/testing/index.js"); - const { EntityHealthComponent } = await import("@minecraft/server"); - const sb = await createSandbox({}); - const dim = sb.world.getDimension("overworld"); - const fox = dim.spawnEntity("minecraft:fox", { x: 0, y: 64, z: 0 }); - const health = fox.getComponent(EntityHealthComponent.componentId); - assert.equal(health.currentValue, 20); - const hurt = []; - const hpChanged = []; - sb.world.afterEvents.entityHurt.subscribe((ev) => hurt.push(ev.damage)); - sb.world.afterEvents.entityHealthChanged.subscribe((ev) => { - hpChanged.push({ old: ev.oldValue, neu: ev.newValue }); - }); - assert.equal(fox.applyDamage(7), true); - assert.equal(health.currentValue, 13); - assert.deepEqual(hurt, [7]); - assert.ok(hpChanged.some((r) => r.old === 20 && r.neu === 13)); - assert.equal(fox.applyDamage(0), false); - health.resetToMaxValue(); - assert.equal(health.currentValue, 20); - const died = []; - sb.world.afterEvents.entityDie.subscribe((ev) => died.push(ev.deadEntity.id)); - fox.applyDamage(100); - assert.equal(fox.isValid, false); - assert.deepEqual(died, [fox.id]); - - const p = sb.addPlayer({ name: "Tank" }); - const ph = p.getComponent("minecraft:health"); - assert.equal(ph.currentValue, 20); - p.applyDamage(5); - assert.equal(ph.currentValue, 15); - await sb.dispose(); -}); - -test("L2 第二批:itemUse / playerBreakBlock / entityHitEntity emit + getEntitiesOfType", async () => { - const { createSandbox } = await import("./dist/esm/testing/index.js"); - const { ItemStack } = await import("@minecraft/server"); - const sb = await createSandbox({}); - const p = sb.addPlayer({ name: "Ev" }); - const uses = []; - const breaks = []; - const hits = []; - sb.world.afterEvents.itemUse.subscribe((ev) => uses.push(ev.source.name)); - sb.world.afterEvents.playerBreakBlock.subscribe((ev) => breaks.push(ev.player.name)); - sb.world.afterEvents.entityHitEntity.subscribe((ev) => { - hits.push([ev.damagingEntity.id, ev.hitEntity.id]); - }); - sb.emit.itemUse(p, new ItemStack("minecraft:stick", 1)); - sb.emit.playerBreakBlock(p, { block: p.dimension.getBlock(p.location) }); - const fox = p.dimension.spawnEntity("minecraft:fox", { x: 1, y: 64, z: 0 }); - sb.emit.entityHitEntity(p, fox); - assert.deepEqual(uses, ["Ev"]); - assert.deepEqual(breaks, ["Ev"]); - assert.deepEqual(hits, [[p.id, fox.id]]); - p.dimension.spawnEntity("minecraft:cow", { x: 2, y: 64, z: 0 }); - assert.equal(p.dimension.getEntitiesOfType("minecraft:fox").length, 1); - assert.equal(p.dimension.getEntitiesOfType("cow").length, 1); - await sb.dispose(); -}); - -test("L2 第三批:addEffect / getEffect / removeEffect 状态袋", async () => { - const { createSandbox } = await import("./dist/esm/testing/index.js"); - const sb = await createSandbox({}); - const dim = sb.world.getDimension("overworld"); - const fox = dim.spawnEntity("minecraft:fox", { x: 0, y: 64, z: 0 }); - const speed = fox.addEffect("speed", 200, { amplifier: 1 }); - assert.ok(speed); - assert.equal(speed.typeId, "minecraft:speed"); - assert.equal(speed.duration, 200); - assert.equal(speed.amplifier, 1); - assert.equal(fox.getEffect("minecraft:speed")?.amplifier, 1); - assert.equal(fox.getEffects().length, 1); - // 同 type 覆盖 - fox.addEffect("speed", 40, { amplifier: 2 }); - assert.equal(fox.getEffect("speed")?.duration, 40); - assert.equal(fox.getEffect("speed")?.amplifier, 2); - assert.equal(fox.getEffects().length, 1); - assert.equal(fox.removeEffect("speed"), true); - assert.equal(fox.getEffect("speed"), undefined); - assert.equal(fox.addEffect("poison", 0), undefined); - - const p = sb.addPlayer({ name: "Buff" }); - p.addEffect("minecraft:regeneration", 100); - assert.equal(p.getEffects().map((e) => e.typeId).join(","), "minecraft:regeneration"); - assert.equal(p.removeEffect("regeneration"), true); - await sb.dispose(); -}); - -test("L2 本批:playSound / onScreenDisplay / spawnPoint / playerLeave", async () => { - const { createSandbox } = await import("./dist/esm/testing/index.js"); - const sb = await createSandbox({}); - const p = sb.addPlayer({ name: "Hud" }); - p.playSound("bucket.fill_water"); - assert.deepEqual(p.soundLog, ["bucket.fill_water"]); - p.onScreenDisplay.setTitle("Hello", { subtitle: "World" }); - assert.equal(p.onScreenDisplay.title, "Hello"); - assert.equal(p.onScreenDisplay.subtitle, "World"); - p.onScreenDisplay.setActionBar("bar"); - assert.equal(p.onScreenDisplay.actionBar, "bar"); - p.onScreenDisplay.updateSubtitle("2"); - assert.equal(p.onScreenDisplay.subtitle, "2"); - assert.equal(p.getSpawnPoint(), undefined); - p.setSpawnPoint({ dimension: p.dimension, x: 10, y: 70, z: -3 }); - const sp = p.getSpawnPoint(); - assert.equal(sp?.x, 10); - assert.equal(sp?.y, 70); - assert.equal(sp?.z, -3); - - let left = null; - sb.world.afterEvents.playerLeave.subscribe((ev) => { - left = ev.playerId; - }); - sb.emit.playerLeave(p); - assert.equal(left, p.id); - assert.equal(sb.world.getPlayers().length, 0); - await sb.dispose(); -}); - -test("L2 本批:未接线 API 仍硬失败(无空成功)", async () => { - const { createSandbox, UnimplementedMinecraftApiError } = await import( - "./dist/esm/testing/index.js" - ); - const sb = await createSandbox({}); - const p = sb.addPlayer({ name: "Hard" }); - assert.throws(() => p.applyImpulse?.({ x: 0, y: 1, z: 0 }), (err) => { - return ( - err instanceof UnimplementedMinecraftApiError || - (err instanceof Error && /未实现的 Minecraft API/.test(err.message)) - ); - }); - assert.throws(() => p.onScreenDisplay.hideAllExcept?.(), (err) => { - return err instanceof Error && /未实现的 Minecraft API/.test(err.message); - }); - await sb.dispose(); -}); - -test("L2:Entity / Player / World 动态属性 Map 袋", async () => { - const { createSandbox } = await import("./dist/esm/testing/index.js"); - const sb = await createSandbox({}); - const dim = sb.world.getDimension("overworld"); - const fox = dim.spawnEntity("minecraft:fox", { x: 0, y: 64, z: 0 }); - const p = sb.addPlayer({ name: "Fly" }); - - // 类型:boolean | number | string | Vector3;缺省 undefined - fox.setDynamicProperty("hpbe:dogefly", true); - assert.equal(fox.getDynamicProperty("hpbe:dogefly"), true); - fox.setDynamicProperty("sfmc:n", 3); - fox.setDynamicProperty("sfmc:s", "fly"); - fox.setDynamicProperty("sfmc:v", { x: 1, y: 2, z: 3 }); - assert.equal(fox.getDynamicProperty("sfmc:n"), 3); - assert.equal(fox.getDynamicProperty("sfmc:s"), "fly"); - assert.deepEqual(fox.getDynamicProperty("sfmc:v"), { x: 1, y: 2, z: 3 }); - assert.equal(fox.getDynamicProperty("missing"), undefined); - assert.deepEqual(fox.getDynamicPropertyIds().sort(), [ - "hpbe:dogefly", - "sfmc:n", - "sfmc:s", - "sfmc:v", - ]); - fox.setDynamicProperty("sfmc:n", undefined); - assert.equal(fox.getDynamicProperty("sfmc:n"), undefined); - assert.ok(!fox.getDynamicPropertyIds().includes("sfmc:n")); - fox.setDynamicProperties({ "sfmc:a": 1, "sfmc:b": false }); - assert.equal(fox.getDynamicProperty("sfmc:a"), 1); - assert.equal(fox.getDynamicProperty("sfmc:b"), false); - assert.ok(fox.getDynamicPropertyTotalByteCount() > 0); - fox.clearDynamicProperties(); - assert.deepEqual(fox.getDynamicPropertyIds(), []); - assert.equal(fox.getDynamicProperty("hpbe:dogefly"), undefined); - - // Player 独立袋(不与 Entity / World 共享) - p.setDynamicProperty("hpbe:dogefly", true); - assert.equal(p.getDynamicProperty("hpbe:dogefly"), true); - assert.equal(fox.getDynamicProperty("hpbe:dogefly"), undefined); - assert.equal(sb.world.getDynamicProperty("hpbe:dogefly"), undefined); - p.setDynamicProperty("hpbe:dogefly", null); - assert.equal(p.getDynamicProperty("hpbe:dogefly"), undefined); - p.setDynamicProperties({ a: "x", b: 2 }); - assert.deepEqual(p.getDynamicPropertyIds().sort(), ["a", "b"]); - p.clearDynamicProperties(); - assert.deepEqual(p.getDynamicPropertyIds(), []); - - // World 已有袋:补 clear / TotalByteCount / setDynamicProperties - sb.world.setDynamicProperty("w:k", "v"); - sb.world.setDynamicProperties({ "w:n": 9 }); - assert.equal(sb.world.getDynamicProperty("w:k"), "v"); - assert.equal(sb.world.getDynamicProperty("w:n"), 9); - assert.ok(sb.world.getDynamicPropertyTotalByteCount() > 0); - sb.world.clearDynamicProperties(); - assert.deepEqual(sb.world.getDynamicPropertyIds(), []); - - fox.remove(); - assert.throws(() => fox.setDynamicProperty("x", 1), /InvalidEntityError/); - assert.throws(() => fox.getDynamicProperty("x"), /InvalidEntityError/); - p.kill(); - assert.throws(() => p.getDynamicPropertyIds(), /InvalidEntityError/); - await sb.dispose(); -}); diff --git a/docs/en/index.mdx b/docs/en/index.mdx index bee64cfd..ae9691aa 100644 --- a/docs/en/index.mdx +++ b/docs/en/index.mdx @@ -17,30 +17,30 @@ hero: link: https://github.com/Tanya7z/sfmc-modules features: - - title: ⚡ Deep Native SAPI Integration - details: Built directly on official Minecraft Script API standards. Lightweight, stable, and zero engine hacks. + - title: ⚡ Native SAPI Integration + details: Built directly on official Minecraft Script API standards with zero client modifications. link: /guide/ - - title: 🧩 Modular Hot-Loading Ecosystem - details: Isolate and deliver features in modular packages with dynamic toggling and hot reloads via sfmc CLI. + - title: 🧩 Modular Architecture + details: Deliver features in independent packages with dynamic enable/disable and automated bundling. link: /guide/modules - - title: 🗄️ Inter-Process Storage & Services - details: Built-in SQLite HTTP daemon and Loopback IPC bridge providing transactional persistence to SAPI. + - title: 🗄️ Persistence & Sidecars + details: Built-in SQLite HTTP daemon providing reliable database operations and transactions to SAPI. link: /guide/services - title: 🤖 Multi-Platform Bridge & QQ Bot - details: Connect in-game chat and events with QQ Official OpenAPI & LLBot for auto-moderation and whitelist sync. + details: Connect in-game chat and events with QQ Official OpenAPI & LLBot for moderation and whitelist sync. link: /guide/qq-bridge - - title: 🛠️ Full-Stack TypeScript DevKit - details: Dedicated SDK, custom ESLint static analysis rules, and TypeDoc signatures for seamless developer ergonomics. + - title: 🛠️ TypeScript DevKit + details: Dedicated SDK, custom ESLint linting rules, and type definitions with local watch hot-reloading. link: /dev/module-author - - title: 🔍 Interactive Troubleshooting Wizard - details: Step-by-step diagnostic trees to pinpoint port conflicts, authorization errors, and startup failures in seconds. + - title: 🔍 Troubleshooting Wizard + details: Step-by-step diagnostic trees to pinpoint port conflicts, authorization errors, and startup failures. link: /guide/troubleshooting ---

- Build your first SFMC module in 5 minutes + Build your first SFMC module

Standardized lifecycle, strict type safety, declarative command & permission registrations. @@ -55,7 +55,7 @@ Command.register( "hello", "hello.use", (player) => { - if (player) Msg.send(player, "§aHello! Welcome to your SFMC-powered Minecraft server!"); + if (player) Msg.info("Hello! Welcome to your SFMC-powered Minecraft server!", player); }, "Send greeting message", "feature-hello" @@ -66,7 +66,7 @@ ModuleRegistry.register({ afterWorldLoad: false, lifecycle: { registerPermissions() { - Permission.register("hello.use", "Permission to use hello", 0); + Permission.register("hello.use", 0); }, }, }); diff --git a/docs/superpowers/plans/2026-09-02-module-loader-cold-boot-cleanup.md b/docs/superpowers/plans/2026-09-02-module-loader-cold-boot-cleanup.md deleted file mode 100644 index f665417f..00000000 --- a/docs/superpowers/plans/2026-09-02-module-loader-cold-boot-cleanup.md +++ /dev/null @@ -1,330 +0,0 @@ -# module-loader 冷启动收敛 Implementation Plan - -> **For agentic workers:** Prefer subagent-driven-development (task-by-task + reviews). -> Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 放弃 SAPI 热启停,删除相关半成品 API;收敛 `cleanup`/`teardown` 为「模块自管 + auth 清理」;按序修守卫策略、分层、announce、afterWorldLoad、死面与注释。 - -**Architecture:** 冷启动唯一真相:`installHostBootstrap` → `ConfigManager.init` → `bootAll` →(可选)`announceLoaded` → `worldLoad` → `bootAfterWorldLoad` → `shutdown` → `teardown`。启停只影响**下次** BDS 启动时装哪些模块;运行中 `ConfigManager.isEnabled` 反映启动时缓存,不再提供 `refreshModules`/`reconcile`。指令生命周期由模块在 `registerCommands`/`cleanup` 自管,loader 不再 stub 注销。 - -**Tech Stack:** `@sfmc-bds/sdk` module-loader / sapi;文档 `AGENTS.md`、`docs/zh/dev/architecture.md`;兄弟仓 `sfmc-modules` AdminGUI 跟删。 - -**Spec:** 用户 2026-09-02 决策(本对话)+ 既有审查看板 `module-loader-review.canvas.tsx` - ---- - -## 决策记录:禁用模块指令守卫怎么修 - -**结论:不接线** `setModuleGuard`**;删除 install 头注释中的虚假承诺;**`command.ts` **的** `setModuleGuard` **保留为可选扩展点(不从 install 调用)。** - -理由: - -1. 冷启动下未启用模块不会进 `bootModule`,也就不会跑 `registerCommands`——正常路径下禁用模块**没有**指令可拦。 -2. 热启停已删除,「运行中 isActive 变 false 但指令仍在表里」不再是设计状态;再接线守卫会暗示可变运行时契约,与本次收敛矛盾。 -3. 一行接线成本虽低,但会把 AdminGUI/文档再次引向「运行时禁用」幻觉。 -4. 若模块在 entry 顶层副作用里直接 `Command.register`(违规),应用 eslint / 代码审查约束,而不是 loader 假装能补救。 - -**不做:** 在 `installHostBootstrap` 里调用 `setModuleGuard((id) => ModuleRegistry.isActive(id))`。 - ---- - -## File Map - -| 文件 | 责任 | -| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `modules/sdk/@sfmc-sdk/src/module-loader/runtime.ts` | 删热启停 / track / Command stub / bootTasks / guardEvent;收敛 cleanup;修 afterWorldLoad;内联 `ModuleId` | -| `modules/sdk/@sfmc-sdk/src/module-loader/internal/module-keys.ts` | **删除**(类型并入 `runtime.ts`) | -| `modules/sdk/@sfmc-sdk/src/module-loader/internal/config-manager.ts` | 删 `refreshModules` / `onModuleEnabledChange` / listener 广播 | -| `modules/sdk/@sfmc-sdk/src/module-loader/data-adapter.ts` | 删 `getModules`(仅热刷新用);修正注释 | -| `modules/sdk/@sfmc-sdk/src/module-loader/http-data-adapter.ts` | 删 `getModules` 实现;注释改为「install 内部装配,非公开子路径」 | -| `modules/sdk/@sfmc-sdk/src/module-loader/install.ts` | 删 setModuleGuard 承诺、`ModuleSurface`、`enabledModuleIds`、`snapshotEnabled`;正确调用 `announceLoaded` | -| `modules/sdk/@sfmc-sdk/src/module-loader/index.ts` | 收窄公开导出与头注释 | -| `modules/sdk/@sfmc-sdk/src/sapi/runtime/permission.ts` | 改走公开 `module-loader` 出口,不碰 `internal/` | -| `AGENTS.md`、`docs/zh/dev/architecture.md` | 启动序列与「无运行中启停」对齐 | -| `sfmc-modules/packages/gui/sapi/src/AdminGUI.ts` | 去掉 `refreshModules`;文案改为「下次重启生效」 | - -**Out of scope(本计划不改,除非编译被迫):** `_sandbox_archive/`**(已归档)、db-server 的 HTTP enable/disable API(仍可写 lock,供**下次**启动读)、`setModuleGuard` 在 `command.ts` 的实现本体。 - ---- - -### Task 1: 删除热启停与冗余 cleanup 追踪 - -**Files:** - -- Modify: `modules/sdk/@sfmc-sdk/src/module-loader/runtime.ts` -- Modify: `modules/sdk/@sfmc-sdk/src/module-loader/internal/config-manager.ts` -- Modify: `modules/sdk/@sfmc-sdk/src/module-loader/data-adapter.ts` -- Modify: `modules/sdk/@sfmc-sdk/src/module-loader/http-data-adapter.ts` -- Modify: `modules/sdk/@sfmc-sdk/src/module-loader/install.ts` -- Modify: `modules/sdk/@sfmc-sdk/src/module-loader/index.ts` - -- [ ] **Step 1:** `runtime.ts` **删除热启停与 stub** - -从 `ModuleRegistry` / 文件级删除: - -- `lastEnabled`、`clearLastEnabled`、`snapshotEnabled`、`reconcile` -- `trackCleanup` / `trackCommand` / `trackSystemRun`、`cleanups` Map、`CleanUpFn` 类型(若无其它引用) -- `_cmdUnregister` / `_cmdUnregisterByModule` 及 Stage F 注释 -- `bootTasks`、`guardEvent`(若本任务一并删死 API;否则留给 Task 6——**本计划约定 Task 1 只删热启停相关,**`bootTasks`**/**`guardEvent` **放到 Task 6**) - -收敛 `cleanupModule(id)` 为: - -```ts -static cleanupModule(id: ModuleId): void { - const d = ModuleRegistry.get(id); - if (!d) return; - try { - d.lifecycle.cleanup?.(); - } catch (e) { - debug.e("Module", `[${id}] cleanup hook failed`, e); - } - booted.delete(id); - // afterWorldLoad 相关状态位在 Task 4 引入后一并清理 - _authHooks?.clear(id); -} -``` - -`teardown()` 仍遍历 `cleanupModule`(shutdown 时模块自管资源 + 清 auth)。 - -- [ ] **Step 2:** `ConfigManager` **删除热刷新面** - -删除: - -- `onModuleEnabledChange` -- `_moduleChangeListeners`、`_notifyModuleChanges` -- `refreshModules` -- `init()` 里对 `_notifyModuleChanges` 的调用 -- 头注释中「通过 onModuleEnabledChange 订阅」表述;保留「无热重载 / 改配置后重启 BDS」 - -保留:`bindDataAdapter` / `init` / `loadAll` / `isEnabled` / token / configKey / settings / permissions / `resetForTesting`。 - -- [ ] **Step 3:** `DataAdapter` **收窄** - -`data-adapter.ts` 删除 `getModules()` 方法与相关 JSDoc。 - -`http-data-adapter.ts` 删除对应实现行。 - -- [ ] **Step 4:** `install.ts` **/** `index.ts` **去热启停调用** - -- 启动序列改为:`await ConfigManager.init()` → `ModuleRegistry.bootAll()` →(Task 3)`announceLoaded()`;**不再** `snapshotEnabled()` -- 删除头注释第 5 条(setModuleGuard) -- `index.ts` 头注释去掉 `onModuleEnabledChange`;停止导出已删符号(`guardEvent` 若仍在则 Task 6 再删) - -- [ ] **Step 5: Verify** - -Run(git bash): - -```bash -pnpm --filter @sfmc-bds/sdk exec tsc7 --noEmit -p tsconfig.types.json -# 或 -pnpm --filter @sfmc-bds/sdk build -``` - -Expected: 通过;全仓(主仓)无对已删符号的引用(`rg reconcile|snapshotEnabled|refreshModules|onModuleEnabledChange|trackCleanup|trackCommand|trackSystemRun` 在 `modules/sdk` 与 `docs`/`AGENTS.md` 中应为 0;`_sandbox_archive` 可残留)。 - -- [ ] **Step 6: Commit**(仅当用户允许) - -```bash -git add modules/sdk/@sfmc-sdk/src/module-loader AGENTS.md docs/zh/dev/architecture.md -git commit -m "refactor(sdk/module-loader): drop hot enable/disable and command-track stubs" -``` - ---- - -### Task 2: 指令守卫策略落地(文档 + 注释,不接线) - -**Files:** - -- Modify: `modules/sdk/@sfmc-sdk/src/module-loader/install.ts`(确认无 setModuleGuard 调用与承诺) -- Modify: `AGENTS.md` -- Modify: `docs/zh/dev/architecture.md` -- Optional comment: `modules/sdk/@sfmc-sdk/src/sapi/runtime/command.ts`(`setModuleGuard` JSDoc 注明:冷启动模型下 install 不注入;可选测试/自定义 host) - -- [ ] **Step 1: 文档改写启动与启停语义** - -`AGENTS.md` / `architecture.md`: - -- 启动:`init` → `bootAll` →(announce)→ `worldLoad` → `bootAfterWorldLoad` → `shutdown` → `teardown` -- **删除**「运行中启停:… → refreshModules → reconcile」整段 -- 改为:db-server / AdminGUI 的 enable/disable 只改 lock/catalog,**下次** `start bds` / 装载闸门后生效;当前进程内已 boot 模块不自动 teardown - -- [ ] **Step 2: Verify** - -`rg "reconcile|refreshModules|setModuleGuard|snapshotEnabled" AGENTS.md docs/zh/dev/architecture.md modules/sdk/@sfmc-sdk/src/module-loader` -Expected: install 无 setModuleGuard 承诺;文档无热启停链路。 - -- [ ] **Step 3: Commit**(若用户允许;可与 Task 1 合并为一次提交) - ---- - -### Task 3: `announceLoaded` 去遮蔽 - -**Files:** - -- Modify: `modules/sdk/@sfmc-sdk/src/module-loader/install.ts` - -- [ ] **Step 1: 正确调用 runtime 导出** - -删除文件底部本地空函数 `announceLoaded` 与延迟 import 把戏。顶部: - -```ts -import { announceLoaded, bindModuleAuthHooks, ModuleRegistry, type BdsSystem } from "./runtime.js"; -``` - -startup 订阅末尾直接:`announceLoaded();` - -- [ ] **Step 2: Verify** - -静态阅读 startup 回调;`rg "function announceLoaded" modules/sdk/@sfmc-sdk/src/module-loader/install.ts` → 无匹配。 - ---- - -### Task 4: 完整 `afterWorldLoad` 语义 - -**Files:** - -- Modify: `modules/sdk/@sfmc-sdk/src/module-loader/runtime.ts` - -- [ ] **Step 1: 拆分「已注册生命周期」与「已 init」** - -引入 `initialized: Set`(或等价),约定: - -| 阶段 | `afterWorldLoad: false` | `afterWorldLoad: true` | -| -------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `bootModule` | register → `init` → 加入 `booted` + `initialized` | register → 加入 `booted`;**若** `worldLoaded` 已真则立刻 `init` 并加入 `initialized` | -| `bootAfterWorldLoad` | 跳过 | 对 `booted` 且未 `initialized` 且 active 的模块:`applyModuleAuthContext` → `init` → 加入 `initialized` | -| `cleanupModule` | 调 `lifecycle.cleanup`;从 `booted`/`initialized` 删除;`_authHooks.clear` | 同左 | - -禁止 `bootAfterWorldLoad` 对已 `initialized` 的模块再次 `init`。 - -- [ ] **Step 2:** `isBooted` **语义** - -保持 `isBooted` = 已完成 register 阶段(在 `booted` 集合)。若公开需要「已 init」,可新增 `isInitialized(id)`;**仅当**现有调用方需要时再导出,否则先 private Set 即可。 - -- [ ] **Step 3:** `resetForTesting` - -清空新 Set。 - -- [ ] **Step 4: Verify** - -`pnpm --filter @sfmc-bds/sdk build` -手动推理两条路径:冷启动 `afterWorldLoad:true`(boot 无 init → worldLoad 一次 init);若测试里先设 `worldLoaded` 再 `bootModule`(单次 init,不双跑)。 - ---- - -### Task 5: `permission` 不再直达 `internal/` - -**Files:** - -- Modify: `modules/sdk/@sfmc-sdk/src/sapi/runtime/permission.ts` - -- [ ] **Step 1: 改 import** - -```ts -import { ConfigManager } from "../../module-loader/index.js"; -``` - -(包内相对公开 barrel;不要 `@sfmc-bds/sdk/module-loader` 字符串,避免自引用解析歧义——若仓库惯例是 package name 且已验证可用,可二选一,但**禁止** `internal/config-manager`。) - -- [ ] **Step 2: Verify** - -确认无环:`module-loader` → 不 import `permission.ts`。 -`rg "module-loader/internal" modules/sdk/@sfmc-sdk/src/sapi` → 0。 - ---- - -### Task 6: 删死 API / 空选项 / `module-keys` / http 注释 - -**Files:** - -- Modify: `runtime.ts`、`index.ts`、`install.ts`、`data-adapter.ts`、`http-data-adapter.ts` -- Delete: `internal/module-keys.ts` - -- [ ] **Step 1: 死 API** - -删除(若 Task 1 未删):`bootTasks`、`guardEvent` 及 `index.ts` 导出。 - -`install.ts` 删除未使用的: - -- `ModuleSurface` 接口 -- `InstallOptions.enabledModuleIds` - -- [ ] **Step 2:** `ModuleId` **内联** - -在 `runtime.ts`:`export type ModuleId = string;` -删除 `internal/module-keys.ts` 及其 import。 - -- [ ] **Step 3:** `http-data-adapter` **/** `data-adapter` **注释** - -`data-adapter.ts`:改为「由 `module-loader/install` 经 `createHttpDataAdapter` 装配;非独立 package exports 子路径」。 - -`http-data-adapter.ts`:同样标明 **install 内部实现,未进** `package.json#exports`。 - -- [ ] **Step 4: Verify** - -```bash -rg "module-keys|bootTasks|guardEvent|ModuleSurface|enabledModuleIds|getModules" modules/sdk/@sfmc-sdk/src/module-loader -pnpm --filter @sfmc-bds/sdk build -``` - -Expected: 无残留;构建通过。 - ---- - -### Task 7: 兄弟仓 AdminGUI 跟删 - -**Files:** - -- Modify: `../sfmc-modules/packages/gui/sapi/src/AdminGUI.ts`(相对 ScriptsForMinecraftServer) - -- [ ] **Step 1: 去掉** `refreshModules` - -`onToggle` 成功 POST 后: - -- 不再 `await ConfigManager.refreshModules()` -- 成功文案改为明确「已写入,重启 BDS 后生效」(creative/survival 的 `applyRuntimeState` 可保留——那是 area 连锁,不是 module-loader 热启停) -- Toggle 展示仍读 `ConfigManager.isEnabled`(启动缓存);若需避免 UI 与缓存立刻不一致:toggle 失败回滚,或成功后提示用户当前进程仍按旧状态运行 - -推荐文案行为: - -```ts -Msg.success(`${name} 已记录为${val ? "启用" : "禁用"}(重启后生效)`, this.player); -``` - -- [ ] **Step 2: Verify** - -在 `sfmc-modules` 对 gui 包 typecheck(按该仓既有脚本)。 -Expected: 无 `refreshModules` 引用。 - ---- - -## 验收清单(全部 Task 完成后) - -- [ ] SDK build / typecheck 通过 -- [ ] `modules/sdk/@sfmc-sdk` 内无:`reconcile`、`snapshotEnabled`、`refreshModules`、`onModuleEnabledChange`、`trackCleanup`、`trackCommand`、`trackSystemRun`、`bootTasks`、`guardEvent`、`module-keys` -- [ ] `install` 正确调用真正的 `announceLoaded`;无 setModuleGuard 接线与虚假头注释 -- [ ] `permission.ts` 不引用 `internal/` -- [ ] `afterWorldLoad` 路径不会双 `init` -- [ ] `AGENTS.md` / architecture 描述冷启动 only -- [ ] AdminGUI 不再调用 `refreshModules` - ---- - -## 建议提交粒度 - -1. Task 1–2(删热启停 + 文档) -2. Task 3–6(announce / afterWorldLoad / permission / 死面) -3. Task 7(sfmc-modules,若该仓独立 git 则单独提交) - ---- - -## Handoff - -计划已写入 `docs/superpowers/plans/2026-09-02-module-loader-cold-boot-cleanup.md`。 - -请选择执行方式: - -1. **Subagent-Driven** — 每 Task 一个子代理 + 审查(推荐) -2. **Inline Agent** — 本会话按 Task 顺序直接改 -3. **Stop** — 你稍后自己跑 - diff --git a/docs/superpowers/plans/2026-09-09-client-resource-pack-repair.md b/docs/superpowers/plans/2026-09-09-client-resource-pack-repair.md deleted file mode 100644 index ad1a3964..00000000 --- a/docs/superpowers/plans/2026-09-09-client-resource-pack-repair.md +++ /dev/null @@ -1,91 +0,0 @@ -# 客户端资源包错误修复 Implementation Plan - -> 本计划只描述下一轮修改;在用户确认前不改动世界资源包。建议按阶段执行,每一阶段单独回档、重载和验收。 - -- [ ] 使用 Subagent-Driven Development 按阶段实施并在每阶段后复核 - -**Goal:** 清除当前 Content Log 中除 TouHouLittleMaid 外、已定位到 MineCars、Farmer's Delight、DogeLake、Wheat、Slash Blade 与 Improvised Guns 的资源错误,同时尽量保持玩法和视觉表现不变。 - -**Architecture:** 先固定启用包清单和日志基线,再处理能够确定正确目标的引用错误;对缺失原始资产或陈旧整页 UI 覆盖采用“停用损坏入口”的保守策略。每个包独立备份和验收,禁止跨包猜测性补资源。 - -**Tech Stack:** Minecraft Bedrock Edition Resource/Behavior Pack JSON、Molang、PowerShell、Content Log。 - -**Spec:** `D:\WorkPlace\SFMC\BDS\worlds\Bedrock level` 当前实际启用的资源包;旧 Improvised Guns UUID `3841f7bc-e35a-444e-804d-16cfc21d0f26` 已由用户卸载。 - -## 文件地图 - -| 模组 | 计划修改位置 | 目的 | -|---|---|---| -| Improvised Guns | `[RP] ImprovisedGuns\entity\firearm_forger.json` | 停用无法解析且无可恢复正文的占位文件 | -| DogeLake | `[RP] [自研] DogeLake!\render_controllers\galaxy_blue.json` | 将误放的客户端实体改为合法 render controller | -| Wheat | `[RP] [自研] [RP] wheat\sounds\sound_definitions.json`、`sounds.json`、`blocks.json` | 处理缺失音乐资产、大小写和格式版本 | -| Slash Blade | `[RP] 抜刀剣アドオン Slash Blade Addon\sounds\sound_definitions.json`、`ui\settings_screen.json` | 修正溺水音效路径并停用过期设置页覆盖 | -| MineCars | `[RP] minecars_rp_V3_4\entity\minecar_bumper.json`、debris 实体与 render controller、相关 geometry | 补齐确定引用,拆分不兼容的共享控制器,处理 locator 冲突 | -| Farmer's Delight | `[RP] pack.name` | 只做回归验证;首轮修复已完成 | - -## Task 1:冻结基线并建立可恢复备份 - -- [ ] 确认 `world_resource_packs.json` 中旧 UUID 不再出现,且当前 Improvised Guns UUID `1d4a303d-68e1-47bc-aa14-56211500e2f3`、版本 `3.0.5` 仍启用。 -- [ ] 清空客户端 Content Log,重新进入世界一次,保存新的原始日志;后续只以这份日志判断卸载旧包后的剩余错误,避免修复已自然消失的 `rev:hunting_musket` locator 冲突。 -- [ ] 将本轮涉及文件复制到新的时间戳目录 `.sfmc-repair-backup-`,保留相对目录结构和 SHA-256 清单。 -- [ ] 验证备份可读且文件数量与清单一致;任何修改前先停止 BDS 或确认世界未在写入。 - -## Task 2:Improvised Guns 旧包卸载后的收尾 - -- [ ] 检查新日志中 `rev:hunting_musket` 与 `rev:shadow_hunting_musket` 的 locator 冲突是否已消失;若消失,不再改这两个实体。 -- [ ] 将当前包内 `entity\firearm_forger.json` 重命名为 `entity\firearm_forger.json.disabled`。该文件只是带 `__error__` 的 99 字节生成占位符,备份中也没有有效客户端实体正文,不能据此臆造定义。 -- [ ] 保留 firearm forger 的方块、geometry、terrain texture 和语言条目,不改玩法侧定义。 -- [ ] 重载验证:锻造台方块仍能放置、显示和交互;日志不再报告 `firearm_forger.json` 解析失败;若方块显示受影响,立即从备份恢复该文件并把此项标为需要上游原包。 - -## Task 3:DogeLake Galaxy 渲染控制器 - -- [ ] 用合法 `render_controllers` 文档替换 `render_controllers\galaxy_blue.json` 中误放的 `minecraft:client_entity`:注册 `controller.render.dogelake.galaxy_blue`,使用 `Geometry.default`、`Material.default`、`Texture.default`。 -- [ ] 不改正确的 `entity\galaxy_blue.json`;其 `doge_grid` material、`geometry.dogelake.gird_red` geometry 和 `animation.dogelake.gird_red` animation 均保留。 -- [ ] JSON 全量解析后进服生成/观察 galaxy_blue,确认模型、纹理和动画正常,日志中不再出现 render controller 根节点错误。 - -## Task 4:Wheat 缺失音乐资产与格式警告 - -- [ ] 采用默认保守方案:从 `sounds\sound_definitions.json` 删除 `music.trumpet.zero`、`music.accordion.zero`、`music.NOKIA.zero`、`music.symphony.zero` 四个指向不存在音频文件的定义,并从 `sounds.json` 删除对应事件绑定。结果是四种音乐装置暂时静音,但不会持续请求不存在的文件。 -- [ ] 不使用其他音频冒充原曲,也不凭空创建空 `.ogg`。若用户随后提供四个原始音频,则改走“恢复资产”方案:放回 `sounds\music\`、统一事件名为全小写、category 设为 `music`,并恢复事件绑定。 -- [ ] 在 `blocks.json` 顶层加入 `"format_version": "1.1.0"`,保持当前引擎此前采用的兼容默认版本,不顺带迁移方块 schema。 -- [ ] 验证 Wheat 方块可加载;日志中不再出现四项 missing sound/category/case 警告和 `blocks.json` 缺少版本警告。 - -## Task 5:Slash Blade 音效与过期设置页 - -- [ ] 将四个 `sounds/damage/drown1..4` 引用改为实际存在的 `sounds/mob/player/hurt/drown1..4`。 -- [ ] 将整页旧版 `ui\settings_screen.json` 重命名为 `.json.disabled`。该文件覆盖原版设置页且引用已移除的 `vr_button`、`vr_section`;停用整页比只删两个控件更能避免版本升级后继续污染原版 UI。 -- [ ] 验证四个音效文件存在且 JSON 可解析;进入设置页确认使用当前原版布局;测试拔刀剑物品、模型与核心操作,确认模组并不依赖该设置页覆盖。 -- [ ] 若模组确有自定义设置项依赖此文件,则回档,并改为只删除 `vr_button`、`vr_section` 两个陈旧控件后复测。 - -## Task 6:MineCars 第二轮确定性引用修复 - -- [ ] 在 `entity\minecar_bumper.json` 增加 `geometry.rocket -> geometry.minecar_rocket` 与 `texture.rocket -> textures/entity/overlays/rocket`,与同包普通矿车的 `controller.render.rocket` 契约保持一致。 -- [ ] 对 `balloon_debris`、`chest_debris`、`shooter_debris` 分别建立实体专用 render controller;每个控制器只枚举该实体实际声明、且行为包 mark variant 能到达的 Geometry/Texture/Material key,不再共享要求更多 `v1..v8` key 的控制器。 -- [ ] 修改三个客户端实体指向各自控制器;对每个 mark variant 逐一生成实体,确认碎片模型正确,且日志不再报告缺失 `Geometry.v*`、`Texture.v*`、`Material.v*`。 -- [ ] 针对 `mc:harvester_data_storage`、`mc:railplacer_data_storage` 的 `exhaust` locator 冲突,先在实际命中的 geometry 变体中比较同名 locator 坐标:坐标相同则从子骨骼删除重复项;坐标不同则重命名子 locator,并同步唯一的粒子引用。不得直接删除有不同坐标的 locator。 -- [ ] 生成两种数据存储实体并观察排烟位置;确认 locator conflict 消失且粒子未偏移。 - -## Task 7:Farmer's Delight 首轮修复回归 - -- [ ] 确认 `sounds/block/skillet/add_food2` 存在且 sound definition 只引用该实际文件。 -- [ ] 确认开发测试实体保持为 `entity\test.json.disabled`,正式实体和配方未引用它。 -- [ ] 在游戏内使用平底锅加食材,确认音效正常;日志不再出现旧 `add_food` 路径和 test entity 错误。 - -## Task 8:总体验证与交付 - -- [ ] 对所有本轮修改包执行递归 JSON 解析;预期本轮修改文件 0 个语法错误。MineCars BP 中既存的 JSON5/注释文件单独列出,不借本轮静默格式化。 -- [ ] 静态搜索确认旧错误串为 0:`sounds/damage/drown`、四个缺失 Wheat 音频路径、DogeLake render controller 中的 `minecraft:client_entity`、启用状态下的 `firearm_forger.json` 占位符。 -- [ ] 重启 BDS、客户端重新进服并覆盖主要实体/方块/UI 场景,保存修复后 Content Log,与 Task 1 基线按“错误签名 + 次数”对比。 -- [ ] 只把 TouHouLittleMaid 错误和仍未能归属的新错误列入下一阶段;若本计划引入新错误,先按包回档,不进入下一阶段。 -- [ ] 输出变更清单、备份目录、修复前后错误计数和仍存错误归属。世界目录不是当前 Git 仓库,不执行提交;如用户需要版本化,再单独建立补丁归档。 - -## 审阅时需要确认的两项行为变化 - -1. Wheat:默认让四个缺失源文件的音乐装置静音;若要保留音乐,执行前必须取得四个原始 `.ogg`。 -2. Slash Blade:默认停用整个旧版设置页覆盖;如需保留其中的自定义设置,只做两个 VR 控件的最小删除。 - -## 执行方式 - -- **Subagent-Driven(推荐):** 当前会话按 Task 逐项实施,每一阶段完成静态复核后再进入下一项。 -- **Inline Agent:** 当前会话连续执行全部任务,最后统一进服验收;速度较快,但定位回归的粒度较粗。 -- **Stop:** 仅保留此计划,暂不实施。 diff --git a/docs/superpowers/plans/2026-09-09-module-config-seeding.md b/docs/superpowers/plans/2026-09-09-module-config-seeding.md deleted file mode 100644 index 2f52e8ad..00000000 --- a/docs/superpowers/plans/2026-09-09-module-config-seeding.md +++ /dev/null @@ -1,209 +0,0 @@ -# Module Config Seeding Implementation Plan - -> **For agentic workers:** Prefer subagent-driven-development (task-by-task + reviews). -> Steps use checkbox (\`- [ ]\`) syntax for tracking. - -**Goal:** 模块安装、更新或 link 后,自动创建并安全补全 \`configs/.json\`。 - -**Architecture:** 以模块包内 \`configs-default/.json\` 为唯一默认配置源,在 CLI \`afterInstall\` 更新 catalog/lock 前完成校验与递归补缺。核心合并和原子写入放入独立 helper;脚手架、静态检查和文档统一到同一路径约定。 - -**Tech Stack:** Node.js 22+、ES modules、TypeScript/JavaScript、Node test runner、Changesets。 - -**Spec:** \`docs/superpowers/specs/2026-09-09-module-config-seeding-design.md\` - ---- - -### Task 1: 实现默认配置播种内核 - -**Files:** - -- Create: \`packages/cli/scripts/module-install/lib/config-seeding.mjs\` -- Create: \`packages/cli/scripts/module-install/test/config-seeding.test.ts\` - -- [ ] **Step 1: 先写递归补缺测试** - -覆盖:保留已有标量、数组和未知字段;递归补充缺失对象字段;不修改输入对象;新增点路径按字典序返回。 - -- [ ] **Step 2: 实现 \`mergeMissing(current, defaults)\`** - -只递归 plain object;数组、null 和标量均为原子值。返回 \`{ value, addedPaths }\`。 - -- [ ] **Step 3: 先写文件播种测试** - -覆盖目标不存在、目标无变化、递归补缺、默认 JSON 损坏、用户 JSON 损坏、顶层非对象、非法 configKey、路径穿越和默认文件缺失。损坏用户文件的测试须比较操作前后原始字节一致。 - -- [ ] **Step 4: 实现 \`seedModuleConfig(options)\`** - -返回状态 \`created | updated | unchanged | missing-defaults\`、目标路径和新增点路径。源必须位于模块根目录,目标必须位于 SFMC 工作根目录。写入使用同目录唯一临时文件加原子 rename;失败时清理临时文件且不触碰原目标。 - -- [ ] **Step 5: 验证 helper** - -Run: \`node --test --import tsx/esm "packages/cli/scripts/module-install/test/config-seeding.test.ts"\` -Expected: 全部通过,0 fail。 - -### Task 2: 接入全部模块安装来源 - -**Files:** - -- Modify: \`packages/cli/scripts/module-install/fetch-module.mjs\` -- Modify: \`packages/cli/scripts/module-install/test/install-resolver.test.ts\` -- Modify: \`packages/cli/scripts/module-install/test/link-local.test.ts\` -- Modify: \`packages/cli/scripts/module-install/test/local-resolver.test.ts\` - -- [ ] **Step 1: 先写安装边界测试** - -断言播种发生在 catalog/lock 写入前;播种硬失败时不产生新的 catalog/lock 状态。覆盖 copy/zip 与 link,确认 link 只读源模块仓。 - -- [ ] **Step 2: 在 \`afterInstall\` 调用播种 helper** - -从已安装 manifest 读取 configKey。播种成功或返回 missing-defaults 后,才执行 catalog/lock 更新。npm、tgz/zip、dir、link 都必须走该入口。 - -- [ ] **Step 3: 添加稳定日志** - -输出 created、updated(含排序后的点路径)、unchanged 或 missing defaults,并使用相对 SFMC 工作目录的路径。 - -- [ ] **Step 4: 验证安装器** - -Run: \`node --test --import tsx/esm "packages/cli/scripts/module-install/test/**/*.test.ts"\` -Expected: 全部通过,0 fail。 - -### Task 3: 扩展 check-modules 配置契约 - -**Files:** - -- Modify: \`packages/cli/scripts/module-install/check-modules.mjs\` -- Create: \`packages/cli/scripts/module-install/test/check-modules-config.test.ts\` - -- [ ] **Step 1: 写配置契约测试** - -覆盖标准文件合法、缺失默认文件仅警告、同目录错名 JSON、损坏 JSON、顶层非对象。 - -- [ ] **Step 2: 增加结构化 warnings** - -保持现有 \`{ ok, error, summary }\` 契约并增加 warnings。默认文件缺失返回 warning;文件存在但名称或结构无效返回 \`ok: false\`。CLI 应打印警告。 - -- [ ] **Step 3: 验证 check-modules** - -Run: \`node --test --import tsx/esm "packages/cli/scripts/module-install/test/**/*.test.ts"\` -Expected: 新旧模块契约测试全部通过。 - -### Task 4: 完善 create-module 脚手架 - -**Files:** - -- Create: \`packages/create-module/templates/base/configs-default/{{configKey}}.json\` -- Modify: \`packages/create-module/templates/base/package.json\` -- Modify: \`packages/create-module/src/create-module.test.ts\` - -- [ ] **Step 1: 先扩展生成测试** - -断言生成 \`configs-default/hello_mod.json\`,内容为 JSON 对象,且生成后的 package.json files 包含 \`configs-default\`。 - -- [ ] **Step 2: 新增内容为 \`{}\` 的默认配置模板** - -复用现有 \`copyRenderedTree\` 对文件名的模板渲染,不添加专用重命名逻辑。 - -- [ ] **Step 3: 将 \`configs-default\` 加入模板 package.json files** - -- [ ] **Step 4: 验证 create-module** - -Run: \`node packages/tools/tsc7.mjs --noEmit -p packages/create-module/tsconfig.json\` -Run: \`node --test --import tsx/esm packages/create-module/src/create-module.test.ts\` -Expected: 两条命令均退出 0。 - -### Task 5: 统一 ESLint 默认配置解析 - -**Files:** - -- Modify: \`modules/sdk/@sfmc-eslint-plugin/src/utils/config-fields.ts\` -- Create: \`modules/sdk/@sfmc-eslint-plugin/src/utils/config-fields.test.ts\` -- Modify: \`modules/sdk/@sfmc-eslint-plugin/src/rules/valid-config-key.test.ts\` - -- [ ] **Step 1: 先写标准路径测试** - -在临时模块创建 manifest 和 \`configs-default/.json\`,断言读取业务顶层字段并过滤 \`$schema\` 与下划线内部键。 - -- [ ] **Step 2: 将标准路径放到候选列表首位** - -首选 \`/configs-default/.json\`;保留三个旧格式候选、工作目录配置和平台 schema 回退。 - -- [ ] **Step 3: 扩展 valid-config-key 测试** - -标准默认配置中存在的键通过,未定义键继续产生原有 warning。 - -- [ ] **Step 4: 验证 ESLint 插件** - -Run: \`node packages/tools/tsc7.mjs --noEmit -p modules/sdk/@sfmc-eslint-plugin/tsconfig.json\` -Run: \`pnpm --filter @sfmc-bds/eslint-plugin test\` -Expected: 类型检查与测试退出 0。 - -### Task 6: 更新文档与发布记录 - -**Files:** - -- Modify: \`docs/zh/guide/config.md\` -- Modify: \`docs/en/guide/config.md\` -- Modify: \`docs/zh/dev/conventions.md\` -- Modify: \`docs/zh/dev/cheatsheet.mdx\` -- Modify: \`docs/en/dev/cheatsheet.mdx\` -- Modify: \`packages/create-module/templates/base/README.md\` -- Create: \`.changeset/.md\` - -- [ ] **Step 1: 修正用户配置生命周期文档** - -说明模块包默认值与工作目录持久配置的区别,以及安装/更新只补缺、不覆盖的规则。 - -- [ ] **Step 2: 修正作者指南** - -要求作者在默认配置中列出全部可配置字段;禁止仅为播种而申请 config:write 权限或在启动时调用 config.set。 - -- [ ] **Step 3: 添加 patch changeset** - -changeset 覆盖 \`@sfmc-bds/cli\`、\`@sfmc-bds/create-module\`、\`@sfmc-bds/eslint-plugin\`。 - -- [ ] **Step 4: 搜索冲突旧文案** - -Run: \`rg -n "Defaults are supplied by the module on first write|默认值.*首次写入|config\\.set.*默认" docs packages/create-module/templates\` -Expected: 不再存在与新生命周期冲突的文案。 - -### Task 7: 全量验证与端到端验收 - -**Files:** - -- Verify only: Tasks 1–6 的全部变更 - -- [ ] **Step 1: 格式与差异检查** - -Run: \`git diff --check\` -Expected: 无空白错误。 - -- [ ] **Step 2: 运行受影响包的完整验证** - -Run: - -\`\`\`powershell -pnpm --filter @sfmc-bds/cli typecheck -pnpm --filter @sfmc-bds/cli test -pnpm --filter @sfmc-bds/create-module typecheck -pnpm --filter @sfmc-bds/create-module test -pnpm --filter @sfmc-bds/eslint-plugin typecheck -pnpm --filter @sfmc-bds/eslint-plugin test -\`\`\` - -Expected: 六条命令全部退出 0。 - -- [ ] **Step 3: 在临时 SFMC 工作目录做端到端验收** - -依次验证首次 install 创建配置;人工修改标量和数组;默认配置新增嵌套字段;再次 install/update 只补新字段;link 结果一致且源模块仓无修改。 - -- [ ] **Step 4: 复核工作区范围** - -Run: \`git status --short\` -Expected: 仅有本计划实施文件和实施前已存在的用户改动,无构建产物或临时文件。 - -- [ ] **Step 5: 提交(仅在用户授权时)** - -\`\`\`bash -git add -git commit -m "feat(cli): seed module configuration defaults" -\`\`\` diff --git a/docs/superpowers/plans/2026-09-09-native-module-command-migration.md b/docs/superpowers/plans/2026-09-09-native-module-command-migration.md deleted file mode 100644 index 7c518fcf..00000000 --- a/docs/superpowers/plans/2026-09-09-native-module-command-migration.md +++ /dev/null @@ -1,78 +0,0 @@ -# Native Module Command Migration Implementation Plan - -> **For agentic workers:** Prefer subagent-driven-development (task-by-task + reviews). -> Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 将所有独立模块仓的命令声明迁移到 Bedrock 原生启动期注册模型,并逐仓提交、推送。 - -**Architecture:** SDK 在 `system.beforeEvents.startup` 中统一注册 `c:` 命名空间;平台命令暴露为 `/c:`,模块命令暴露为 `/c:_`。模块在文件顶层调用 `Command.register`,SDK 统一把原生命令描述渲染为 `描述 - §7模块名`。 - -**Tech Stack:** TypeScript、Minecraft Bedrock Script API、pnpm/npm workspaces、Git - -**Spec:** user-provided requirements - ---- - -### Task 1: 完善 SDK 原生命令模板 - -**Files:** - -- Modify: `modules/sdk/@sfmc-sdk/src/sapi/runtime/command.ts` -- Modify: `modules/sdk/@sfmc-sdk/test/sapi/runtime/command.test.ts` -- Modify: `packages/cli/src/module-pack-build.ts` -- Test: `modules/sdk/@sfmc-sdk/test/sapi/runtime/command.test.ts` - -- [x] **Step 1:** 原生命令描述由 SDK 统一输出为 `描述 - §7模块名`;平台命令保持原描述。 -- [x] **Step 2:** 让显式 `mod build` 始终重建,避免 SDK 变化被模块指纹缓存错误跳过。 -- [x] **Step 3:** 运行 SDK、CLI 相关类型检查和测试。 -- [ ] **Step 4:** 在核心仓单独提交并推送。 - -### Task 2: 迁移有玩家命令的模块 - -**Files:** - -- Modify: `sfmc-module-afk/sapi/src/index.ts` -- Modify: `sfmc-module-chat/sapi/src/index.ts` -- Modify: `sfmc-module-clean/sapi/src/index.ts` -- Modify: `sfmc-module-coop/sapi/src/index.ts` -- Modify: `sfmc-module-data-backup/sapi/src/index.ts` -- Modify: `sfmc-module-gui/sapi/src/index.ts` -- Modify: `sfmc-module-inventory-switcher/sapi/src/index.ts` -- Modify: `sfmc-module-land/sapi/src/index.ts` -- Modify: `sfmc-module-monitor/sapi/src/index.ts` -- Modify: `sfmc-module-online-time/sapi/src/index.ts` -- Modify: `sfmc-module-qq-link/sapi/src/index.ts` - -- [x] **Step 1:** 把每个 `Command.register` 移到模块顶层,保持权限、处理器、费用和模块 ID 不变。 -- [x] **Step 2:** 更新旧前缀相关注释与测试断言。 -- [x] **Step 3:** 逐仓运行类型检查与测试。 -- [ ] **Step 4:** 逐仓暂存全部未提交改动,分别提交并推送 `main`。 - -### Task 3: 清理无命令模块的遗留生命周期钩子 - -**Files:** - -- Modify: `sfmc-module-activity-log/sapi/src/index.ts` -- Modify: `sfmc-module-area/sapi/src/index.ts` -- Modify: `sfmc-module-chat-sounds/sapi/src/index.ts` -- Modify: `sfmc-module-economy/sapi/src/index.ts` -- Modify: `sfmc-module-fly-area/sapi/src/index.ts` -- Modify: `sfmc-module-gamemode-area/sapi/src/index.ts` -- Modify: `sfmc-module-peace-area/sapi/src/index.ts` -- Modify: `sfmc-module-qa/sapi/src/index.ts` -- Modify: `sfmc-module-spawn-protect/sapi/src/index.ts` - -- [x] **Step 1:** 删除空的 `registerCommands` 生命周期属性和过时说明。 -- [x] **Step 2:** 逐仓运行类型检查与测试。 -- [ ] **Step 3:** 逐仓暂存全部未提交改动,分别提交并推送 `main`。 - -### Task 4: 集成验证 - -**Files:** - -- Inspect: `D:/WorkPlace/SFMC/packs/_build/sfmc-modules/scripts/main.js` -- Inspect: `D:/WorkPlace/SFMC/BDS/worlds/Bedrock level/behavior_packs/sfmc-modules/scripts/main.js` - -- [ ] **Step 1:** 使用 dev 脚本强制构建并部署:`sfmc-dev.ps1 mod reload --force`。 -- [ ] **Step 2:** 确认构建产物包含 `registerNativeCommands`、`customCommandRegistry` 和模块命令名。 -- [ ] **Step 3:** 完整重启 BDS,并确认 `/c:help` 与至少一个模块命令出现在补全列表中。 diff --git a/docs/superpowers/plans/2026-09-12-gui-sdk-absorption.md b/docs/superpowers/plans/2026-09-12-gui-sdk-absorption.md deleted file mode 100644 index cdd6abec..00000000 --- a/docs/superpowers/plans/2026-09-12-gui-sdk-absorption.md +++ /dev/null @@ -1,105 +0,0 @@ -# GUI 模块收编为平台 SDK 能力实施方案 - -> 状态:方案已确认,阶段 1 进行中 -> -> 日期:2026-09-12 - -## 1. 结论 - -`sfmc-module-gui` 的声明式 UI Runtime、DDUI 映射、表单重试和 feature registry 属于平台宿主能力,最终统一进入 `@sfmc-bds/sdk/sapi/ui`。业务模块直接调用 SDK UI API,不再通过 `gui.*` 跨模块 service 间接访问。 - -所有启用模块与 SDK 会聚合进同一个 Behavior Pack `main.js`,因此 SDK 内的 registry 在运行时只有一个实例。UI 文档继续保持纯 JSON;业务动作仍调用模块 manifest 声明的命名 service。 - -## 2. 最终边界 - -| 当前内容 | 最终归属 | -| --- | --- | -| `declarative.ts` | SDK `sapi/ui` 声明式 Runtime | -| `forms.ts` | SDK UI 表单显示、繁忙重试和确认框 | -| `ddui-widgets.ts` | SDK UI 的 DDUI 映射 | -| `disabled-when.ts` | SDK UI 表达式和响应式禁用状态 | -| `nav-stack.ts` | SDK `sapi/runtime` 公共导航基础设施 | -| feature registry | SDK 宿主级全局 registry | -| `gui.registerFeature` 等 service | 迁移期兼容适配器,最终删除 | -| `/c:catalog` 与示例页面 | `sfmc-module-qa` 或 SDK 测试 fixture | -| `configs-default/gui.json` | 平台设置迁移来源;旧文件不自动删除 | -| `sfmc-module-gui` 包和模块目录 | 所有消费方迁移后退役 | - -## 3. SDK API - -业务模块通过 `@sfmc-bds/sdk/sapi/ui` 使用统一门面: - -```ts -import { ui } from "@sfmc-bds/sdk/sapi/ui"; - -const unregister = ui.registerFeature({ - feature: featureUi, - screens, -}); - -await ui.openScreen(player, { - moduleId: "land", - screenId: "land.home", - params: {}, -}); - -unregister(); -``` - -注册函数必须执行共享 `compileUiProject`,不能在 Runtime 维护第二套文档校验。页面 load/action 根据 `feature.moduleId` 获取所属模块的 `ServiceClient`,避免借用 GUI 模块身份执行业务调用。 - -## 4. 迁移阶段 - -### 阶段 1:建立 SDK UI 能力面 - -- 新增 `@sfmc-bds/sdk/sapi/ui` 导出; -- 迁入声明式 Runtime、DDUI 映射、表单重试和 feature registry; -- 复用 SDK contract、validator、MenuNavigator 和 service client; -- 为 registry、导航语义、DDUI 映射和响应式状态增加测试; -- 不修改现有业务模块调用方式。 - -### 阶段 2:GUI 模块变为兼容适配器 - -- `sfmc-module-gui` 不再保存 Runtime 副本; -- `gui.registerFeature`、`gui.unregisterFeature`、`gui.listEntries`、`gui.openScreen` 临时转发到 SDK UI; -- `/c:catalog` 暂时保留,用于客户端目视验收; -- SDK beta 发布前不要求独立模块依赖未发布的子路径。 - -### 阶段 3:迁移消费模块 - -按 land、coop、online-time、chat 的顺序迁移: - -- 改为直接导入 `@sfmc-bds/sdk/sapi/ui`; -- lifecycle init 注册 feature,cleanup 调用注销函数; -- 直接以 `Player` 打开页面; -- 删除 manifest 中 `service:gui.*` 权限与 requires; -- 每个模块执行自己的 typecheck/test,并检查 JSON 工程编译。 - -### 阶段 4:退役 GUI 模块 - -- 将控件目录迁到 `sfmc-module-qa`; -- 将繁忙重试配置迁入平台 settings,保留已有 `configs/gui.json`; -- 全仓确认没有 `gui.*` 调用或 manifest 引用; -- 删除 GUI 模块的市场记录、安装依赖和源码仓内容; -- 不提供长期旧 service 别名。 - -## 5. 发布与运行时验证 - -验证链必须按以下检查点分别记录: - -1. SDK 源码:类型检查与 UI 定向测试; -2. SDK 构建产物:`dist/esm/sapi/ui/index.js` 与声明文件存在; -3. 聚合构建:最终 `main.js` 只包含一份 UI registry; -4. 部署包:世界中的 Behavior Pack 已替换; -5. BDS:启动日志无模块/service 注册错误; -6. 客户端:控件目录和至少一个真实业务页面完成打开、跳转、返回、提交和错误路径验收。 - -阶段完成不得跨越检查点推断。例如 SDK 测试通过不代表聚合包已经部署,也不代表 Minecraft 客户端验收完成。 - -## 6. 恢复策略 - -- 阶段 1 不改变业务模块调用,可直接停止迁移; -- 阶段 2 保留旧 service 适配层,消费模块可以逐个回退; -- 配置迁移只补入平台缺失字段,不覆盖用户值,不删除 `configs/gui.json`; -- 只有全部消费方和部署包扫描干净后才删除 `sfmc-module-gui`。 - diff --git a/docs/superpowers/prompts/README.md b/docs/superpowers/prompts/README.md deleted file mode 100644 index aba9c9a4..00000000 --- a/docs/superpowers/prompts/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# 官方模块 Prompt Suite 索引 - -本目录把「分层提示词体系」持久化为可复用资产,用于按波次高质量开仓 **19 个可实现官方模块**(`daily-task` 搁置)。 -**行为权威仍是** `docs/superpowers/specs/modules/*-design.md` + [AGENT_BRIEF.md](../specs/modules/AGENT_BRIEF.md);提示词不新增契约。 - -## 怎么生效 / 怎么用 - -| 资产 | 路径 | 用法 | -|------|------|------| -| 全局架构守护 | `.cursor/rules/sfmc-module-architecture.mdc` | `alwaysApply: true`,Cursor 在本仓会话自动注入;与既有 standing / source-of-truth rules 并存 | -| 开工通用模板 | `.cursor/skills/sfmc-module-kickoff/SKILL.md` | 对 Agent 说「按 sfmc-module-kickoff 实现 ``」,或把 skill 内模板填空后粘贴 | -| Wave A(5) | [wave-a.md](./wave-a.md) | 复制目标模块代码块整段发给 Agent | -| Wave B(6) | [wave-b.md](./wave-b.md) | 同上 | -| Wave C(8 + deferred) | [wave-c.md](./wave-c.md) | 同上;跳过 `daily-task` | -| 自检验收 | [self-check.md](./self-check.md) | 模块实现后粘贴;审查规范指向 `sfmc-code-review` | - -## 推荐开仓顺序 - -1. 确认 AGENT_BRIEF「平台前置 / 开仓闸门」(进程内 `service.provide` + 本地优先 `service.call`)已满足。 -2. Wave A → B → C;同波次可按依赖图并行,但不得跳过本模块 `requires`。 -3. 每个模块:脚手架(`--official`)→ 专属提示词 → Acceptance criteria → `pnpm run typecheck` + `pnpm run lint` → 实际联调 → [self-check.md](./self-check.md)。 - -## 相关权威 - -- 规格索引:`docs/superpowers/specs/modules/README.md` -- 作者面工具链:`.cursor/skills/sfmc-module-author` -- 代码审查:`.cursor/skills/sfmc-code-review` diff --git a/docs/superpowers/prompts/self-check.md b/docs/superpowers/prompts/self-check.md deleted file mode 100644 index 61c0687e..00000000 --- a/docs/superpowers/prompts/self-check.md +++ /dev/null @@ -1,26 +0,0 @@ -# 模块交付自检与验收提示词 - -完成后对照 skill [sfmc-code-review](../../../.cursor/skills/sfmc-code-review/SKILL.md)(原则细则见同目录 `principles.md`)做自我架构审查。 -**不要**在本文件重复发明审查标准;以该 skill 为权威。 - -将下列提示词发给 Agent(可替换模块短名): - -```markdown -请根据 `.cursor/skills/sfmc-code-review/SKILL.md` 规范,对刚刚实现的模块进行自我架构审查: - -1. 【DRY 检查】:是否存在自建私有钱包、私有审计表或重复逻辑? -2. 【OCP 检查】:是否所有扩展与挂接都经由注册表或插槽模式? -3. 【DIP 检查】:是否只依赖 SDK 公开接口?是否绝对没有跨模块引用源码? -4. 【迪米特法则】:是否仅通过 manifest 声明的 service 交互? -5. 【平台契约】: - - 命令是否同时支持半角 `!` 和全角 `!`? - - 消息是否统一走 `Msg.*`? - - 数据表是否严格为 `sfmc__*`? -6. 立即在终端运行:`pnpm run typecheck` 和 `pnpm run lint`,并完成一次实际联调,向我汇报结果。 -``` - -## 输出期望 - -- 审查报告格式遵循 `sfmc-code-review` 的 BLOCKER / MAJOR / MINOR 模板,并标注违背的原则。 -- 高置信度、可验证的 BLOCKER/MAJOR 应直接给出最小修复补丁。 -- 类型检查、lint 与联调结果如实汇报;失败则先修再宣称完成。 diff --git a/docs/superpowers/prompts/wave-a.md b/docs/superpowers/prompts/wave-a.md deleted file mode 100644 index 27d6e387..00000000 --- a/docs/superpowers/prompts/wave-a.md +++ /dev/null @@ -1,75 +0,0 @@ -# Wave A:核心底座与中枢插槽提供者(5 个) - -开仓前先读 [AGENT_BRIEF.md](../specs/modules/AGENT_BRIEF.md) 与对应 `*-design.md`,并套用 skill `sfmc-module-kickoff`。 -全局铁律见 `.cursor/rules/sfmc-module-architecture.mdc`。 - ---- - -## 1. `economy`(全服资金与账户中枢) - -```markdown -请实现 official module: `economy`。 -权威规范:docs/superpowers/specs/modules/economy-design.md -【关键要求】: -1. 计分板权威性:以 `sfmc_money` 原生计分板为唯一运行时权威余额,DB(`sfmc_economy_accounts` / `transactions`)作为时序审计与离线留档; -2. 账户语法:原生支持玩家 XUID/名称账户,以及机构公账(如 `coop:` 语法); -3. 核心服务暴露:必须真实提供 `economy.account.get` / `credit` / `debit` / `transfer`,支持业务 `idempotencyKey` 幂等防重;提供 `economy.stats.query` 通用统计; -4. 杜绝旧版废弃的任务(dailyTasks)相关逻辑,纯粹内聚于资金流水与转账。 -``` - ---- - -## 2. `monitor`(综合刻速采样与负载监控中枢) - -```markdown -请实现 official module: `monitor`。 -权威规范:docs/superpowers/specs/modules/monitor-design.md -【关键要求】: -1. 零外部依赖:`manifest.requires: []`; -2. 微观 TPS 流水线:维护容量 100 的逐 tick 环形时间戳采样区,微秒级无锁计算实时 TPS,冷启动保底 20.00; -3. 宏观负载流水线:每 600 ticks 周期汇总三维度活跃实体与视距区块,批量落库 `sfmc_monitor_*`; -4. 命令与服务:注册 `!tps`(面向全员带健康色阶输出)与 `!monitor`(面向管理员全服大盘);对外提供 `tps.current`、`tps.status` 与 `monitor.metrics` 接口。 -``` - ---- - -## 3. `area`(空间微内核与区域规则引擎) - -```markdown -请实现 official module: `area`。 -权威规范:docs/superpowers/specs/modules/area-design.md -【关键要求】: -1. 微内核定位:核心纯内存运行,零具体业务硬编码(严禁在 area 内部写游戏模式或飞行逻辑); -2. 空间索引:维护 XZ 平面 AABB 矩形空间边界与点查; -3. 特性插槽(OCP):对外暴露 `area.registerFeature`,统一调度 `AreaFeatureHandler` 的 `onEnter`、`onLeave` 和 `onTick` 声明周期; -4. 动态区域:提供 `area.registerArea` 与 `area.unregisterArea` 供第三方模块(如 land)动态声明边界。 -``` - ---- - -## 4. `online-time`(在线时长统计与排行榜) - -```markdown -请实现 official module: `online-time`。 -权威规范:docs/superpowers/specs/modules/online-time-design.md -【关键要求】: -1. 打点结转模型:严禁每秒轮询累加!进服记录 `sessionStartMs`,实时查询即算即出;每 3 分钟心跳增量结转一次入库(`sfmc_online_time`); -2. 跨天切片:跨午夜 0 点时平滑拆分归档前一日与当日时长; -3. 服务暴露:提供 `onlinetime.byPlayer`(含在线实时增量与 `isOnline` 状态)与 `onlinetime.top`(今日/本月/总榜); -4. 自主向 `gui.registerMenuItem` 挂载 `🕒 在线统计` 菜单项。 -``` - ---- - -## 5. `activity-log`(全服审计与行为日志中枢) - -```markdown -请实现 official module: `activity-log`。 -权威规范:docs/superpowers/specs/modules/activity-log-design.md -【关键要求】: -1. 原生监听与插槽双轨:全面监听进退服、聊天、方块破坏、容器等原生 after-events; -2. 双向服务插槽: - - 提供 `activity.record`:供 coop、land 等模块异步上报治理事件; - - 提供 `activity.query`:支持按 `targetId`、`actorId`、`eventTypePrefix` 及时间区间分页多维检索; -3. 性能保障:采用内存批量缓冲(2000ms 或 100 条)批量刷盘至 `sfmc_activities`;每日自动清理过期保留日志。 -``` diff --git a/docs/superpowers/prompts/wave-b.md b/docs/superpowers/prompts/wave-b.md deleted file mode 100644 index 88d42504..00000000 --- a/docs/superpowers/prompts/wave-b.md +++ /dev/null @@ -1,90 +0,0 @@ -# Wave B:交互中枢、通用服务与自治叶子模块(6 个) - -开仓前确认 Wave A 中本模块 `requires` 的上游已具备可用契约(或按 AGENT_BRIEF 开仓闸门等待 SDK)。 -套用 skill `sfmc-module-kickoff` + 对应 `*-design.md`。 - ---- - -## 6. `gui`(数据驱动 UI 引擎与单页路由中枢) - -```markdown -请实现 official module: `gui`。 -权威规范:docs/superpowers/specs/modules/gui-design.md -【关键要求】: -1. 平台交互中枢:`manifest.requires: []`,纯粹作为微内核表单导航容器; -2. MenuNavigator SPA 单页路由:基于 Observable 状态实现 0ms 零黑屏动态刷新,支持多级面包屑与返回历史; -3. 扩展能力:封装原生 `ActionFormData` / `ModalFormData`,支持 `image`、`spacer`、`divider` 及按钮 `disabled` 状态; -4. 防吞保护:封装 `gui.showForm` 提供 8 秒安全重试管道; -5. 服务暴露:提供 `gui.registerMenuItem`、`gui.registerAdminItem` 与 `gui.openMainMenu`。 -``` - ---- - -## 7. `chat`(聊天管道与消息分流中枢) - -```markdown -请实现 official module: `chat`。 -权威规范:docs/superpowers/specs/modules/chat-design.md -【关键要求】: -1. 独占聊天流:平台唯一独占监听 `world.beforeEvents.chatSend` 的模块,彻底杜绝多模块裸监听冲突; -2. 管道解耦插槽: - - 提供 `chat.registerInterceptor`(前置拦截器插槽,供 qa 等捕获作答); - - 提供 `chat.onMessage`(观察者插槽,供 chat-sounds 等实时监听文本); -3. 发送服务:提供 `chat.broadcast`(全服广播)与 `chat.send`(定向私聊); -4. 命令与样式:支持 `!chat` / `!tell`,自定义称号前缀与彩色格式化。 -``` - ---- - -## 8. `inventory-switcher`(玩家背包多槽位快照与置换服务) - -```markdown -请实现 official module: `inventory-switcher`。 -权威规范:docs/superpowers/specs/modules/inventory-switcher-design.md -【关键要求】: -1. 纯受控服务:零模式监听、零物理双箱或告示牌,纯内存与数据库(`sfmc_inventories`)结构化持久化; -2. 完整栏位覆盖:36格背包、4格装备栏、副手槽位,精确序列化附魔、耐久、lore 与名称; -3. 原子还原:先清空槽位再填充,杜绝复制漏洞; -4. 服务暴露:提供 `inventory.save`、`inventory.restore`、`inventory.switch`、`inventory.clear` 与 `inventory.has`。 -``` - ---- - -## 9. `data-backup`(世界环境与计分板灾备中枢) - -```markdown -请实现 official module: `data-backup`。 -权威规范:docs/superpowers/specs/modules/data-backup-design.md -【关键要求】: -1. 平台基础设施:`canDisable: false`; -2. 引导与灾备快照:定期保存世界种子/游戏规则(`sfmc_world`)、玩家元数据(`sfmc_players`)及全服计分板快照(`sfmc_scoreboards`); -3. 严格排除业务计分板:快照与恢复时**绝对排除 `sfmc_money`**(由 economy 专属权威托管); -4. 提供 OP 管理指令:`scoreboard restore`(灾难恢复)。 -``` - ---- - -## 10. `afk`(位移检测与挂机标记) - -```markdown -请实现 official module: `afk`。 -权威规范:docs/superpowers/specs/modules/afk-design.md -【关键要求】: -1. 极简自治叶子:纯内存位移检测,零数据库表; -2. 原生 Tag 标记:通过原生玩家 Tag `AFK`(挂机中)和 `NOAFK`(豁免自动检测)运作; -3. 指令支持:`!afk`(主动切换)与 `!noafk `(管理员豁免); -4. 玩家产生 ≥ 1.0 格位移时自动解除挂机并广播。 -``` - ---- - -## 11. `spawn-protect`(出生与复活保护) - -```markdown -请实现 official module: `spawn-protect`。 -权威规范:docs/superpowers/specs/modules/spawn-protect-design.md -【关键要求】: -1. 极简自治叶子:`manifest.requires: []`,零数据库表; -2. 精确刻度保护:监听 `world.afterEvents.playerSpawn`(进服与复活),施加 60 ticks(约 3 秒)最高阶伤害抗性(`minecraft:resistance`, amplifier: 5); -3. 防覆盖:玩家若已有同等或更高抗性效果时不逆向缩减。 -``` diff --git a/docs/superpowers/prompts/wave-c.md b/docs/superpowers/prompts/wave-c.md deleted file mode 100644 index 9638ada3..00000000 --- a/docs/superpowers/prompts/wave-c.md +++ /dev/null @@ -1,124 +0,0 @@ -# Wave C:业务消费者与玩法生态模块(8 个可实现 + 1 个搁置) - -依赖 Wave A/B 的中枢插槽(economy / activity-log / chat / area / gui / inventory-switcher 等)。 -开仓闸门见 [AGENT_BRIEF.md](../specs/modules/AGENT_BRIEF.md)。套用 skill `sfmc-module-kickoff`。 - ---- - -## 12. `chat-sounds`(聊天关键字全服音效) - -```markdown -请实现 official module: `chat-sounds`。 -权威规范:docs/superpowers/specs/modules/chat-sounds-design.md -【关键要求】: -1. 严禁裸听原生聊天:必须通过 `chat.onMessage` 观察者插槽接入聊天管道; -2. 关键词匹配:匹配预设关键词(如 ciallo, baka)并向全服广播目标音效; -3. 冷却防刷:生存模式玩家施加 200 ticks 触发冷却,创造模式豁免。 -``` - ---- - -## 13. `coop`(合作社公账与组织治理) - -```markdown -请实现 official module: `coop`。 -权威规范:docs/superpowers/specs/modules/coop-design.md -【关键要求】: -1. 资金账户托管(DRY):严禁自建钱包表!金库公账统一采用 `coop:`,完全由 `economy.account.transfer` 划转; -2. 审计托管(DRY):严禁自建审计表!治理变动统一调用 `activity.record`(`eventType: "coop.audit"`); -3. 治理业务:建社、入社、退社、转让、踢人、金库提现额度与角色权限门禁; -4. 声明式向 `gui.registerMenuItem` 挂载合作社面板。 -``` - ---- - -## 14. `qa`(知识竞答与奖惩系统) - -```markdown -请实现 official module: `qa`。 -权威规范:docs/superpowers/specs/modules/qa-design.md -【关键要求】: -1. 严禁裸听原生聊天:出题广播使用 `chat.broadcast`;答题通过 `chat.registerInterceptor` 注册前置拦截器捕获 `!答案` 并消费; -2. 加权随机出题与最近 5 题防重队列; -3. 奖惩结算:答对调用 `economy.account.credit` 发放节操;答错调用 `economy.account.debit` 扣罚(传入轮次幂等键); -4. 配置文件容错:题库为空或配置损坏时安全降级不崩溃。 -``` - ---- - -## 15. `land`(现代地产庄园与只租不卖生态) - -```markdown -请实现 official module: `land`。 -权威规范:docs/superpowers/specs/modules/land-design.md -【关键要求】: -1. 根本法则:只租不卖(Lease Only),彻底废除永久买断,按日租金起租,支持长租折扣与平滑扩建租金补差; -2. 3D 原生高亮:通过 `@minecraft/debug-utilities` 的 `DebugBox` 绘制结构方块彩色三维线框(本人青绿、他人红、预览黄),`DebugText` 渲染空间名牌; -3. 平台插槽全面闭环: - - 空间边界托管至 `area.registerArea`; - - 治理审计托管至 `activity-log`(`activity.record` 归档,`activity.query` 支撑 `land.auditLog`); - - 租金与门票秒级划转至 `economy.account.debit` / `credit`; - - 交互控制台挂载至 `gui` MenuNavigator SPA; -4. 商业门票与增益插槽(防爆/抑怪/回血/留言板)。 -``` - ---- - -## 16. `gamemode-area`(区域游戏模式切换与背包隔离) - -```markdown -请实现 official module: `gamemode-area`。 -权威规范:docs/superpowers/specs/modules/gamemode-area-design.md -【关键要求】: -1. 挂接空间插槽:实现 `AreaFeatureHandler` 并注册至 `area.registerFeature`(特性名 `gamemode`); -2. 背包安全隔离:进入创造区前调用 `inventory.switch` 暂存生存背包并载入创造专用背包;离开时反向置换; -3. 边界安全处理:玩家下线、死亡或异常移出时必须安全还原模式与背包,防止将创造物品带回生存区。 -``` - ---- - -## 17. `fly-area`(区域飞行赋权) - -```markdown -请实现 official module: `fly-area`。 -权威规范:docs/superpowers/specs/modules/fly-area-design.md -【关键要求】: -1. 挂接空间插槽:注册至 `area.registerFeature`(特性名 `fly`); -2. 进出生命周期:`onEnter` 赋予 `mayFly` 飞行能力,`onLeave` 剥离飞行能力; -3. 下线清理:离线时清理状态,杜绝带飞逃逸。 -``` - ---- - -## 18. `clean`(区域与全服掉落物预警清理) - -```markdown -请实现 official module: `clean`。 -权威规范:docs/superpowers/specs/modules/clean-design.md -【关键要求】: -1. 严格生命周期分工: - - `registerCommands`:注册 `!clean` OP 手动清理指令; - - `registerEvents`:调用 `area.registerFeature` 挂接 `clean` 空间特性; - - `init`:启动世界加载完成后的全服掉落物阈值周期扫描; -2. 优雅清理机制:超限时广播 10 秒倒计时;将常规物品实体优先移入 `recycle_bin` 公共回收箱,高危/经验球实体物理销毁。 -``` - ---- - -## 19. `peace-area`(怪物拦截与和平空间保护) - -```markdown -请实现 official module: `peace-area`。 -权威规范:docs/superpowers/specs/modules/peace-area-design.md -【关键要求】: -1. 挂接空间插槽:注册至 `area.registerFeature`(特性名 `peace`); -2. 生成拦截与周期兜底:监听 `entitySpawn`,在怪物露面前调用 `area.byPoint` 判定并 `remove()` 拦截;`onTick` 兜底清除游荡怪物; -3. 白名单安全豁免:铁傀儡、雪傀儡、友好生物与玩家宠物绝对不受影响。 -``` - ---- - -## 20. `daily-task`(状态:Deferred 搁置) - -> **说明**:该模块已在架构决策中明确标记为 **deferred(搁置)**,当前波次**不需要开仓实现**。 -> 规格见 [daily-task-design.md](../specs/modules/daily-task-design.md)。重新激活前须另开设计修订:明确任务权威存储(自有表 vs 新模块),再写完整章节与验收项。 diff --git a/docs/superpowers/specs/2026-09-09-module-config-seeding-design.md b/docs/superpowers/specs/2026-09-09-module-config-seeding-design.md deleted file mode 100644 index c31e8fb1..00000000 --- a/docs/superpowers/specs/2026-09-09-module-config-seeding-design.md +++ /dev/null @@ -1,145 +0,0 @@ -# 模块默认配置播种设计 - -## 目标 - -模块在安装、更新或本地链接后,SFMC 应根据模块随包携带的默认配置,在工作目录的 `configs/.json` 中创建可立即编辑的模块配置。已存在的服主配置不得被默认值覆盖。 - -## 非目标 - -- 不让 SAPI 模块通过 `config.set()` 自行播种默认值。 -- 不在本期引入配置版本号、字段重命名或删除迁移。 -- 不覆盖、清理或重排用户已有配置。 -- 不将模块私有配置并入平台启动快照。 - -## 已选决策 - -### 唯一默认配置约定 - -模块包内的标准路径为: - -```text -configs-default/.json -``` - -选择该路径是因为现有业务模块已普遍使用它,可避免再引入 `config.default.json` 等竞争格式。`manifest.configKey` 仍是目标文件名和配置命名空间的权威来源。 - -### 执行边界 - -默认配置播种由 CLI 安装流程负责,并统一从 `afterInstall` 路径调用。因此 npm、tgz/zip、dir 和 link 安装源共享同样的行为。 - -`afterInstall` 内必须先完成默认配置验证与播种,再更新 catalog 和 module lock。若配置验证或写入失败,不得将该模块标记为已安装或已启用。 - -安装器具有 SFMC 工作目录的管理权限,因此创建配置文件不要求模块声明 `config:write:`。模块运行时仍按 manifest 权限读写配置。 - -### 合并语义 - -安装或更新时,将默认配置递归补入目标配置: - -- 目标不存在:写入完整默认配置。 -- 目标已存在:仅补充缺失字段。 -- 对象:递归处理缺失字段。 -- 数组和标量:视为完整值;若目标已有该键则不合并、不覆盖。 -- 未知用户字段:原样保留。 -- 默认配置中的 `$schema` 按普通默认字段处理;已存在时不改写。 - -该语义使新版模块可以补入新增字段,同时保证用户设定不会因重装或升级被重置。 - -### 失败与安全规则 - -- `configKey` 必须符合现有安全标识符规则。 -- 默认文件的文件名必须等于 `.json`。 -- 默认配置和已有配置都必须是 JSON 对象,不接受数组或标量顶层。 -- 已有配置 JSON 损坏时,安装或更新必须失败并保留原文件,不得以默认值覆盖。 -- 写入应使用临时文件加原子替换,避免中途中断留下半份 JSON。 -- 从模块包解析的源路径和目标路径必须位于各自预期根目录内,不允许路径穿越。 - -### 无默认配置的兼容性 - -`configKey` 仍为 manifest v2 必填字段,但旧模块可能没有 `configs-default/.json`。本期保持向后兼容: - -- 安装器记录“未提供默认配置”,但不因此拒绝安装。 -- `check-modules` 对缺失默认配置给出警告,对文件存在但名称或 JSON 结构错误给出硬错误。 -- 模块仍可通过代码内 fallback 默认值运行。 - -## 数据流 - -```text -sapi/manifest.json - └─ configKey -configs-default/.json - └─ 模块默认值 - │ - ▼ sfmc mod install/update/link -configs/.json - └─ 服主可编辑的持久化配置 -``` - -`db-server` 和 SAPI `config.get/set` 继续只读写最后的工作目录配置,不在运行时读取 `configs-default` 目录。 - -## 文件与模块边界 - -### CLI 安装器 - -- `packages/cli/scripts/module-install/fetch-module.mjs` - - 在 `afterInstall` 中、catalog/lock 更新之前执行配置验证和播种。 - - 确保 npm、压缩包、目录和 link 来源行为一致。 - - 输出 `created`、`updated`、`unchanged` 或 `missing defaults` 结果。 -- `packages/cli/scripts/module-install/lib/` - - 放置可单测的路径校验、JSON 解析和递归补缺逻辑,避免继续膨胀 `fetch-module.mjs`。 -- `packages/cli/scripts/module-install/check-modules.mjs` - - 检查 manifest、`configKey` 与默认配置的一致性。 - -### 模块脚手架 - -- `packages/create-module/templates/base/configs-default/{{configKey}}.json` - - 新模块默认生成空 JSON 对象。 -- `packages/create-module/templates/base/package.json` - - `files` 列表加入 `configs-default`。 -- `packages/create-module/src/create-module.ts` - - 确保模板文件名可使用 `configKey` 渲染。 - -### ESLint 与静态检查 - -- `modules/sdk/@sfmc-eslint-plugin/src/utils/config-fields.ts` - - 优先读取标准路径 `configs-default/.json`。 - - 保留旧候选路径作为过渡兼容,但文档和脚手架不再生成旧格式。 - -### 文档 - -- 更新中英文配置指南和模块作者文档。 -- 将“默认值由模块首次写入提供”修正为“安装时从 `configs-default` 播种,运行时仅读写工作目录配置”。 - -### Changeset - -CLI、`create-module` 和 ESLint 插件均为可发布包,实施时应添加对应 patch changeset。 - -## 用户可见输出 - -```text -[fetch-module] config created: configs/data_backup.json -[fetch-module] config updated: added scoreboard.ignore_objectives -[fetch-module] config unchanged: configs/data_backup.json -[fetch-module] config defaults missing: data_backup -``` - -当补入多个字段时,`updated` 日志应输出稳定排序的点路径列表,便于测试和用户核对。 - -## 验收标准 - -1. 安装带默认配置的模块后,`configs/.json` 立即存在,无需启动 BDS 或调用 `config.set()`。 -2. npm、tgz/zip、dir 和 link 四种来源的播种结果一致。 -3. 重复安装不会改写任何用户已有值。 -4. 模块更新后,新增的对象字段会递归补入,已有标量和数组保持不变。 -5. 用户配置 JSON 损坏时操作失败、原文件字节不变,并输出可定位的错误。 -6. 新生成模块的 npm 包包含 `configs-default/.json`。 -7. `valid-config-key` 能从标准路径读取默认字段并检查 `config.get/set` 调用。 -8. 无默认配置的旧模块仍可安装,但会得到明确警告。 -9. 安装器单测覆盖创建、不变、递归补缺、损坏 JSON、路径拒绝和旧模块兼容。 -10. 相关 CLI、`create-module` 和 ESLint 测试、类型检查及构建全部通过。 - -## 明确延后 - -- 配置 schema 的模块级标准分发与 `$schema` 自动注入。 -- 配置版本和显式迁移脚本。 -- 自动删除废弃字段或自动重命名。 -- 为现有已安装模块提供独立的批量配置修复命令;本期通过重新安装或更新触发播种。 diff --git a/docs/superpowers/specs/modules/AGENT_BRIEF.md b/docs/superpowers/specs/modules/AGENT_BRIEF.md deleted file mode 100644 index f045e718..00000000 --- a/docs/superpowers/specs/modules/AGENT_BRIEF.md +++ /dev/null @@ -1,110 +0,0 @@ -# 开仓智能体共用简报(AGENT_BRIEF) - -每个官方模块仓开工时:**只读本文件 + 对应 `*-design.md` + 其 `requires` 指向的上游规格**。设计方案是唯一行为权威;旧 archive 代码禁止逐行移植。 - -## 1. 脚手架 - -```bash -pnpm dlx @sfmc-bds/create-module@latest --official -``` - -- install id = 规格中的短名(kebab-case) -- npm:`@sfmc-bds/module-` -- **manifest.id / DESCRIPTOR.id = 同一短名**(例如 `economy`,**不要**写成 `feature-economy` / `core-gui`) -- configKey:短名 `-` → `_` -- 脚手架若写出不一致的 id,立刻改齐再写业务 - -## 2. 依赖与 import 白名单 - -**允许:** - -- `@sfmc-bds/sdk/module-loader` -- `@sfmc-bds/sdk/sapi/runtime`(`Msg` / `Command` / `Permission` / `MenuNavigator` / `Money` / `debug`) -- `@sfmc-bds/sdk/sapi/db` · `sapi/config` · `sapi/service` -- `@minecraft/*` -- manifest `$schema` 指向 SDK schemas - -**禁止:** - -- 裸 `@sfmc-bds/sdk`、SDK 私有路径、`@sfmc/sdk` -- `db-server` / `cli` / `bds-tools` / `module-loader/install` / `sapi/host` / `node/*`(业务 SAPI 模块) -- **任何** `@sfmc-bds/module-*` / 其它模块 `sapi/src` 的 npm 或相对路径依赖 -- 直接 `player.sendMessage`(必须用 `Msg.*`) -- 字符串拼接 SQL(必须用 `sql` 模板标签) -- 跨模块读写他模块表 - -跨模块协作:必须在 manifest 中显式声明 `requires` 与 `services.requires`,运行时统一通过 `service.call`(及文档约定的提供方注册)进行松耦合调用。 - -**平台前置(开仓闸门):** Wave C/D 中凡 `provides` 含 **SAPI 进程内能力**(打开表单、改计分板、订阅世界事件结果等)的模块,依赖 SDK 已提供: - -- `service.provide(name, handler)`(SAPI 进程内注册) -- `service.get` / `service.call`:**优先本地总线**,未命中再 HTTP fallback 到 db-server - -在上述能力合并进 `@sfmc-bds/sdk` 并发布前,**不得**开始依赖 `chat.open*` / `land.openMainMenu` / `area.setCreativeChain*` / 模块侧 `economy.account.*`(计分板实现)的并行实现验收。Wave A 中纯文档可先写;economy 计分板实现亦依赖 SAPI 总线若 account 服务注册在模块进程内。 - -弱依赖:`gui` 作为纯粹的交互导航容器(`manifest.requires: []`),业务模块通过 `gui.registerMenuItem` 声明式挂载菜单项,见 [gui-design.md](./gui-design.md)。 - -## 3. Manifest v2 规范 - -必填字段:`schemaVersion: 2`、`id`、`name`、`configKey`、`requires`、`permissions`、`services.provides`、`services.requires`。 - -禁止使用 v1 遗留字段:`routes` / `tables` / `migrations` / `seeds` / `handlers` / `events`。 -数据库表必须在 `lifecycle.init` 阶段调用 `db.defineTable` 创建,模块私有表统一采用 `sfmc__` 命名空间。 - -`requires` 数组填写**短名**(与被依赖模块的 `manifest.id` 一致),例如 `["economy"]`。 - -## 4. 生命周期契约(Lifecycle) - -```text -registerPermissions → registerCommands → registerEvents → init -shutdown → cleanup -``` - -- 事件订阅一律置于 `registerEvents` 阶段,严禁写入 `init`; -- 需等待世界实装的逻辑显式声明:`afterWorldLoad: true`; -- 平台采用**确定性冷启动机制**:模块启停状态变更仅在下次启动 BDS 时完整生效,禁止假定运行期热插拔。 - -## 5. 交互与权限规范 - -- 命令前缀:统一支持英文半角 `!` 与中文全角 `!`; -- 权限阶梯:严格遵循 `Any(0)` / `Member(1)` / `Admin(2)` / `OP(3)` 标准层级登记; -- 界面规范:表单正文排版使用 `ListFormInfo`;按钮除「返回/关闭」类语义外保持纯文本,不滥用颜色格式代码。 - -权威文档(主仓): - -- `docs/zh/dev/module-author.mdx` -- `docs/zh/dev/manifest.md` -- `docs/zh/dev/conventions.md` -- `AGENTS.md` - -## 6. 门禁与发布 - -```bash -pnpm install -pnpm run typecheck -pnpm run lint -pnpm publish --access public -``` - -然后向 [Tanya7z/sfmc-modules](https://github.com/Tanya7z/sfmc-modules) 的 `index.json` 开 PR,条目形状: - -```json -"": { - "npm": "@sfmc-bds/module-", - "version": "", - "sdk": ">=0.2.0" -} -``` - -## 7. 并行波次建议 - -1. Providers(Wave A):`economy` `monitor` `area` `online-time` `activity-log`(基础数据、时序监控与核心中枢插槽) -2. Services & Leaves(Wave B):`gui`(平台交互导航插槽 Provider,零依赖)、`chat`(平台聊天管道插槽 Provider,依赖 economy)、`inventory-switcher`(背包快照与切换 Provider)、`data-backup`(通用快照与灾备服务 Provider)、`afk` `spawn-protect` -3. Consumers(Wave C):`chat-sounds`(依赖 chat)、`coop`(依赖 economy + activity-log)、`qa`(依赖 economy + chat)、`land`(依赖 economy + activity-log)、`gamemode-area`(依赖 area + inventory-switcher)、`fly-area`(依赖 area)、`clean`(依赖 area)、`peace-area`(依赖 area)(**不含**已 deferred 的 `daily-task`) - - 各业务模块在启动阶段自主向 `gui.registerMenuItem` 挂载入口按钮,无需中央硬编码。 - -## 8. 验收总则 - -- 行为对齐对应 `*-design.md` 的 Acceptance criteria -- 不出现「移植旧文件 xxx」类提交说明 -- GUI / 其它模块不得 `import` 本模块内部类;对外能力一律 `services.provides` diff --git a/docs/superpowers/specs/modules/README.md b/docs/superpowers/specs/modules/README.md deleted file mode 100644 index c6017539..00000000 --- a/docs/superpowers/specs/modules/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# 第一方模块设计方案索引 - -本目录是 **20 个官方业务模块** 的行为权威规格(阶段 1)。实现时按 [AGENT_BRIEF.md](./AGENT_BRIEF.md) 一模块一仓从脚手架重建;旧 `sfmc-modules` archive 仅作参考。 - -**命名(已定):** `manifest.id` ≡ install 短名(如 `economy`)。**不加** `feature-` / `core-` 前缀。`requires` 同样写短名。 - -## 依赖图 - -```mermaid -flowchart BT - economy[economy] - monitor[monitor] - area[area] - onlineTime[online-time] - activityLog[activity-log] - gui[gui] - chat[chat] - coop[coop] - qa[qa] - land[land] - chatSounds[chat-sounds] - gmArea[gamemode-area] - flyArea[fly-area] - clean[clean] - peaceArea[peace-area] - - %% 经济链路 - economy --> chat - economy --> coop - economy --> qa - economy --> land - - %% 审计链路 - activityLog --> coop - activityLog --> land - - %% 聊天插槽链路 - chat --> chatSounds - chat --> qa - - %% 空间插槽链路 (Area Engine) - area --> gmArea - area --> flyArea - area --> clean - area --> peaceArea - - %% 背包服务链路 - invSwitcher[inventory-switcher] --> gmArea - - %% 导航插槽接入 (声明式注册菜单与管理项) - economy -.->|registerMenuItem| gui - land -.->|registerMenuItem| gui - chat -.->|registerMenuItem| gui - coop -.->|registerMenuItem| gui - gmArea -.->|registerAdminItem| gui -``` - -叶子模块(无模块间 `requires` 且纯被动事件监听):`afk` `spawn-protect`。 -**全平台五大中枢能力插槽提供者(Core Slot Providers):** -1. **资金账户插槽**:`economy`(`economy.account.*`,业务零账本负担) -2. **审计日志插槽**:`activity-log`(`activity.record` / `activity.query`,业务零审计表负担) -3. **聊天管道插槽**:`chat`(`chat.registerInterceptor` / `chat.onMessage` / `chat.broadcast` / `chat.send`,独占原生聊天流,业务零裸监听冲突) -4. **空间规则插槽**:`area`(`area.registerFeature` / `area.registerArea` / `area.byPoint`,微内核空间引擎,业务零空间边界循环负担) -5. **交互导航插槽**:`gui`(`gui.registerMenuItem` / `gui.registerAdminItem` / `gui.openMainMenu`,微内核表单导航容器,业务零硬编码菜单负担) - -基础环境与通用服务提供者:`data-backup`(全服世界/玩家/计分板快照检索与灾备服务)· `inventory-switcher`(背包多槽位快照与置换服务)· `monitor`(实时刻速与多维负载综合监控)· `online-time`。 - -**economy 要点(见规格):** 计分板权威余额 + DB 实时留档;无每日任务;`economy.stats.query` 通用统计(非白皮书业务)。`daily-task` 整模块 **deferred**。 - -## 状态表 - -| install id | 规格 | 状态 | 波次 | -|------------|------|------|------| -| economy | [economy-design.md](./economy-design.md) | draft | A | -| monitor | [monitor-design.md](./monitor-design.md) | draft | A | -| area | [area-design.md](./area-design.md) | draft | A | -| online-time | [online-time-design.md](./online-time-design.md) | draft | A | -| activity-log | [activity-log-design.md](./activity-log-design.md) | draft | A | -| gui | [gui-design.md](./gui-design.md) | draft | B | -| afk | [afk-design.md](./afk-design.md) | draft | B | -| data-backup | [data-backup-design.md](./data-backup-design.md) | draft | B | -| inventory-switcher | [inventory-switcher-design.md](./inventory-switcher-design.md) | draft | B | -| spawn-protect | [spawn-protect-design.md](./spawn-protect-design.md) | draft | B | -| chat | [chat-design.md](./chat-design.md) | draft | B | -| chat-sounds | [chat-sounds-design.md](./chat-sounds-design.md) | draft | C | -| coop | [coop-design.md](./coop-design.md) | draft | C | -| daily-task | [daily-task-design.md](./daily-task-design.md) | **deferred** | C(暂缓开仓) | -| qa | [qa-design.md](./qa-design.md) | draft | C | -| land | [land-design.md](./land-design.md) | draft | C | -| gamemode-area | [gamemode-area-design.md](./gamemode-area-design.md) | draft | C | -| fly-area | [fly-area-design.md](./fly-area-design.md) | draft | C | -| clean | [clean-design.md](./clean-design.md) | draft | C | -| peace-area | [peace-area-design.md](./peace-area-design.md) | draft | C | - -## 共享文件 - -| 文件 | 用途 | -|------|------| -| [_TEMPLATE.md](./_TEMPLATE.md) | 单模块规格强制章节 | -| [AGENT_BRIEF.md](./AGENT_BRIEF.md) | 开仓智能体共用规则 | -| [../../prompts/README.md](../../prompts/README.md) | Prompt Suite:全局规则 / 开工模板 / 分波次提示 / 自检 | - -## 阶段与审阅闸门 - -| 步骤 | 内容 | 状态 | -|------|------|------| -| 2 | 规格契约瑕疵(inTx 清除、coop debit/credit、scoreboard 排除 `sfmc_money`、gui 弱依赖、land 表名与两阶段补偿等) | **已完成**(2026-09-04 核对) | -| 3 | 私有表统一 `sfmc__*` | **已完成**(含 `sfmc_lands*` / `sfmc_online_time` / `sfmc_monitor_*` / `sfmc_chat_avatars` 等) | -| 1 | SDK SAPI **进程内** `service.provide` + 本地优先路由 | **未完成(BLOCKER)** — 见 [AGENT_BRIEF.md](./AGENT_BRIEF.md) 开仓闸门 | -| 4 | 规格 `draft` → `approved` + 开仓指令 | **阻塞于步骤 1**;步骤 1 合并后由人工将状态表改为 `approved` | - -1. 规格保持 `draft`,直至步骤 1 落地并人工确认 -2. 其后并行智能体按波次开仓(不在本阶段) diff --git a/docs/superpowers/specs/modules/_TEMPLATE.md b/docs/superpowers/specs/modules/_TEMPLATE.md deleted file mode 100644 index 39928800..00000000 --- a/docs/superpowers/specs/modules/_TEMPLATE.md +++ /dev/null @@ -1,87 +0,0 @@ -# `` — Design Spec - -> **Status:** draft -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** … - -**Non-goals:** - -- 不移植旧仓目录结构、文件拆分或历史实现风格 -- 不依赖其它业务模块的 npm 源码包(跨模块只走 manifest + `service` / `tx`) - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id / index key | `` | -| npm | `@sfmc-bds/module-` | -| manifest.id / DESCRIPTOR.id | ``(与 install id 相同,**不加** `feature-` / `core-` 前缀) | -| configKey | `` | -| enabledByDefault | true / false | -| canDisable | true / false | -| manifest.requires | `[]` | - -## 3. Player surface - -### Commands - -| 命令 | 权限节点 | 权限等级 | 行为摘要 | -|------|----------|----------|----------| -| … | … | Any / Member / Admin / OP / Root | … | - -### GUI - -- … - -## 4. Data - -| 表名 | 归属 | 关键列 / 语义 | -|------|------|----------------| -| `sfmc__` | 本模块 `defineTable` / 平台 bootstrap | 关键列定义、主键、索引与软删除语义 | - -跨模块严格禁止越权读写他模块私有表。 - -## 5. Config - -`configs/.json` 形状与默认值要点: - -```json -{} -``` - -## 6. Services - -### provides - -| name | input | output | 语义 | -|------|-------|--------|------| -| … | … | … | … | - -### requires - -| name | 来自规格 | 用途 | -|------|----------|------| -| … | [`…-design.md`](./…-design.md) | … | - -## 7. Lifecycle notes - -- 冷启动 only(启停写入 lock,下次 BDS 启动生效) -- `afterWorldLoad`: true / false -- 钩子顺序:`registerPermissions` → `registerCommands` → `registerEvents` → `init`;关机 `cleanup` - -## 8. Acceptance criteria - -- [ ] `schemaVersion: 2` manifest;`id` ≡ install id;无 v1 字段 -- [ ] `pnpm run typecheck && pnpm run lint` 通过,并完成实际联调 -- [ ] …(模块特有行为) - -## 9. Deferred - -- … - -## 10. Legacy reference(仅参考) - -- 路径 / rev:`sfmc-modules` `archive/monorepo-packages` → `packages//`(land:`git show c8ec85f:packages/land/...`) diff --git a/docs/superpowers/specs/modules/activity-log-design.md b/docs/superpowers/specs/modules/activity-log-design.md deleted file mode 100644 index 2b1baac3..00000000 --- a/docs/superpowers/specs/modules/activity-log-design.md +++ /dev/null @@ -1,146 +0,0 @@ -# activity-log — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 全面监听玩家进退服、公屏聊天、方块破坏放置、容器存取、战斗伤害与实体爆炸等关键原生事件;**同时作为全服行为审计中枢,对外提供标准化的事件摄入插槽(`activity.record`)与多维检索插槽(`activity.query`)**,供其他业务模块(如合作社 `coop`、领地 `land` 等)统一异步上报业务治理与安全审计日志,并在管理面板中按需检索;基于内存环形/批处理队列异步聚合落库,并提供基于保留期的自动数据淘汰修剪机制。 - -**Non-goals:** -- 模块专注底层数据存取服务,不自建复杂的玩家交互 GUI(交互呈现交由调用方领域 GUI 或集中式运维面板消费) -- 杜绝旧版大量重复硬编码的分散事件处理器,采用统一的事件分流与规范化管道 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `activity-log` | -| npm | `@sfmc-bds/module-activity-log` | -| manifest.id | `activity-log` | -| configKey | `activity_log` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `[]` | - -## 3. Player surface - -纯后台审计记录,无玩家命令、无表单界面。 - -## 4. Data - -| 表名 | 关键列 / 语义 | -|------|----------------| -| `sfmc_activities` | `id` PK,`timestamp`(Unix ms),`event_type`,`actor_id`,`actor_name`,`target_id`,`dimension`,`x`,`y`,`z`,`level`(info/warn/error),`payload_json`,`created_at` | - -对 `(event_type, timestamp)` 与 `(target_id, timestamp)` 建立索引,确保按类型或按目标实体审计追踪的高效性。 - -## 5. Config - -`configs/activity_log.json`: - -```json -{ - "retention_days": 30, - "flush_interval_ms": 2000, - "batch_size": 100 -} -``` - -未配置时自动采用上述内置默认参数。 - -## 6. Services - -### provides - -| name | input | output | 语义 | -|------|-------|--------|------| -| `activity.record` | 见下方入参信封 | `{ ok: boolean }` | 统一事件摄入插槽:供其他业务模块上报领域治理/审计日志 | -| `activity.query` | 见下方查询入参 | `{ records: ActivityRecord[], total: number }` | 审计日志检索端点:按目标实体、操作者、事件前缀及时间范围多维检索 | - -**`activity.record` input 契约:** - -```ts -export interface ActivityRecordInput { - /** 事件命名空间标识,例如 "coop.create"、"coop.member_kick"、"land.transfer" */ - eventType: string; - /** 操作者标识(玩家 ID、管理员 ID 或 "system") */ - actorId?: string; - /** 操作者名称快照 */ - actorName?: string; - /** 目标实体标识(例如合作社 cid、领地 landId 或受影响玩家 ID) */ - targetId?: string; - /** 发生空间位置 */ - dimension?: string; - x?: number; - y?: number; - z?: number; - /** 严重级别,默认 "info" */ - level?: "info" | "warn" | "error"; - /** 领域扩展详情字典(自动序列化入 payload_json) */ - payload?: Record; -} -``` - -**`activity.query` input / output 契约:** - -```ts -export interface ActivityQueryParams { - /** 目标实体标识(例如合作社 cid、领地 landId 或玩家 ID) */ - targetId?: string; - /** 操作者标识过滤 */ - actorId?: string; - /** 事件类型前缀过滤,例如 "coop." 或 "land." */ - eventTypePrefix?: string; - /** 起始时间戳 (Unix ms) */ - from?: number; - /** 截止时间戳 (Unix ms) */ - to?: number; - /** 分页拉取条数,默认 20,最大 100 */ - limit?: number; - /** 分页偏移量,默认 0 */ - offset?: number; -} - -export interface ActivityRecord { - id: number; - timestamp: number; - eventType: string; - actorId?: string; - actorName?: string; - targetId?: string; - dimension?: string; - x?: number; - y?: number; - z?: number; - level: "info" | "warn" | "error"; - payload?: Record; - createdAt: string; -} -``` - -### requires - -无(作为通用中枢,绝对不反向依赖上游业务模块)。 - -## 7. Lifecycle notes - -- `afterWorldLoad`: **false**(可在启动期完成事件订阅挂载) -- 异步批量写入:原生监听事件与 `activity.record` 摄入事件统一推入内存队列,每 2000ms 或达到 `batch_size` 时批量执行 `db.insert` 刷盘;数据库瞬时不可用时保留队列指数重试; -- 维护调度:每日低峰期自动执行 `DELETE FROM sfmc_activities WHERE timestamp < :cutoff` 批量清理过期日志;`cleanup` 时将内存残留队列同步刷新入库。 - -## 8. Acceptance criteria - -- [ ] 完整覆盖原生进退服、聊天、方块、物品、容器、战斗与爆炸等关键 after-events -- [ ] 真实注册并对外暴露 `activity.record` 摄入与 `activity.query` 检索插槽,外部模块可成功上报并分页检索 -- [ ] 批量入库机制稳定,不阻断主线程游戏刻运行 -- [ ] 过期数据自动淘汰清理机制生效 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 官方集中式 Web 审计大屏与深度统计分析报表 - -## 10. Legacy reference(仅参考) - -- `packages/activity-log/` diff --git a/docs/superpowers/specs/modules/afk-design.md b/docs/superpowers/specs/modules/afk-design.md deleted file mode 100644 index 769cccc5..00000000 --- a/docs/superpowers/specs/modules/afk-design.md +++ /dev/null @@ -1,77 +0,0 @@ -# afk — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 基于玩家位移与交互行为实现低开销的挂机检测(AFK),自动标注状态并广播全服;支持玩家手动切换挂机状态,并允许管理员为特权用户施加挂机豁免标记。 - -**Non-goals:** -- 模块纯内存运行,不引入数据库持久化 -- 首版不引入挂机自动踢出(Kick)或经济扣费惩罚 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `afk` | -| npm | `@sfmc-bds/module-afk` | -| manifest.id | `afk` | -| configKey | `afk` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `[]` | - -## 3. Player surface - -| 命令 | 权限节点 | 权限等级 | 行为摘要 | -|------|----------|----------|----------| -| `afk` | `afk.use` | Member | 玩家主动切换进入/退出挂机状态 | -| `noafk` | `afk.clear.other` | Admin | 为指定目标玩家添加或移除 `NOAFK` 挂机豁免标签 | - -## 4. Data - -纯内存与原生标签,无数据库表。使用原生玩家 Tag:`AFK`(挂机中)、`NOAFK`(免疫自动检测)。 - -## 5. Config - -`configs/afk.json`: - -```json -{ - "afk_time": 120, - "step_time": 15 -} -``` - -- `afk_time`:空闲判定阈值(秒),超过此时间无有效位移则判定为挂机; -- `step_time`:检测轮询间隔(秒)。 -未配置时默认采用上述设定。 - -## 6. Services - -无。 - -## 7. Lifecycle notes - -- `afterWorldLoad`: **false** -- 周期判定机制:定期采样在线玩家坐标,当欧氏位移 $\ge 1.0$ 方块时重置空闲计时;当空闲时长达到 `afk_time` 且无 `NOAFK` 标签时,自动打上 `AFK` 标签并通过 `Msg.broadcast` 播报; -- 状态唤醒:处于 `AFK` 状态的玩家产生有效移动后,立即剥离标签并播报重返游戏; -- 边界处理:玩家进服(`playerSpawn`)或离线时及时复位内存坐标缓存与瞬态标签。 - -## 8. Acceptance criteria - -- [ ] 挂机检测阈值与轮询步长由配置精确驱动 -- [ ] 手动执行 `!afk` 与自动超时打标广播行为正确 -- [ ] 拥有 `NOAFK` 标签的玩家绝对豁免自动挂机判定 -- [ ] 玩家重返移动后标签与广播即时恢复 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- AFK 踢出 / 经济惩罚 - -## 10. Legacy reference(仅参考) - -- `packages/afk/` diff --git a/docs/superpowers/specs/modules/area-design.md b/docs/superpowers/specs/modules/area-design.md deleted file mode 100644 index 0ac45d07..00000000 --- a/docs/superpowers/specs/modules/area-design.md +++ /dev/null @@ -1,134 +0,0 @@ -# area — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 构建微内核空间引擎与**全平台空间拓扑与区域特性插槽提供者(Spatial Engine & Area Slot Provider)**。负责维护全服区域几何定义、空间网格索引、实体/玩家边界移动检测(`onEnter` / `onLeave`)与调度循环(`onTick`);核心内部**零业务硬编码**,对外通过 `area.registerFeature` 提供统一的区域特性组件挂接插槽,并提供动态建区(`area.registerArea`)与点查服务,使官方特性(如生存/创造、飞行、清理、和平)及第三方扩展能以纯声明式方式组合与执行空间规则。 - -**Non-goals:** -- **核心模块内严禁内置具体业务逻辑**:诸如游戏模式切换、飞行赋权、掉落物回收、怪物过滤等业务能力全部剥离为独立的下游特性模块(`gamemode-area`、`fly-area`、`clean`、`peace-area`),本模块仅作为纯粹的空间与调度底座 -- 首版几何判定统一采用指定维度内的 **XZ 平面轴对齐矩形(AABB)**,复杂三维多面体或体素级选区留待后续扩展 -- 严禁向外部导出内部空间树或领域类,跨模块调用与特性注册严格经由标准化 **service** 调度 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `area` | -| npm | `@sfmc-bds/module-area` | -| manifest.id | `area` | -| configKey | `area` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `[]` | - -## 3. Player surface - -纯空间底座与调度中枢,无面向普通玩家的指令面。各具体区域特性若有交互命令(如 `!clean`),由对应的下游特性模块独立承载与注册。 - -## 4. Data - -纯内存空间索引结构,无数据库持久化表。空间定义源自 `configs/area.json` 静态配置及运行时动态声明。 - -## 5. Config - -`configs/area.json` 采用纯声明式的空间与特性挂载结构: - -```json -{ - "scan_interval_ticks": 20, - "areas": [ - { - "name": "spawn_hub", - "dimension": "minecraft:overworld", - "start": [-64, -64], - "end": [64, 64], - "features": { - "peace": {}, - "fly": {}, - "gamemode": { "mode": "survival", "enforce": true } - } - }, - { - "name": "creative_park", - "dimension": "minecraft:overworld", - "start": [1000, 1000], - "end": [1500, 1500], - "features": { - "gamemode": { "mode": "creative" } - } - } - ] -} -``` - -- `scan_interval_ticks`:移动追踪与常驻调度的全局轮询周期(默认 20 ticks / 1秒); -- `features` 字段采用**声明式参数字典**:键名对应已注册的特性 `featureId`(如 `peace`、`fly`、`gamemode`、`clean`),值为传递给特性处理器的私有参数对象; -- **解耦容错机制**:若某区域声明了特定 feature(例如 `fly`),但对应的业务模块未安装或被禁用,`area` 仅打印 debug 警告并正常放行其余特性,绝不阻断开服或空间索引。 - -## 6. Services - -### 统一区域特性接口 (AreaFeatureHandler) - -挂接在 `area` 上的特性模块需在 SAPI 进程内实现如下接口契约: - -```ts -export interface AreaContext { - readonly name: string; - readonly dimension: string; - readonly box: { minX: number; minZ: number; maxX: number; maxZ: number }; - readonly params: Record; -} - -export interface AreaFeatureHandler> { - readonly id: string; - onEnter?(player: Player, ctx: AreaContext, params: TParams): void; - onLeave?(player: Player, ctx: AreaContext, params: TParams): void; - onTick?(ctx: AreaContext, params: TParams): void; -} -``` - -### provides - -| name | input | output | 语义 | -|------|-------|--------|------| -| `area.registerFeature` | `{ id: string, handler: AreaFeatureHandler }` | `{ ok: boolean }` | **特性插槽注册**:下游模块(如 fly-area、gamemode-area)挂接生命周期处理器 | -| `area.registerArea` | `AreaDefinition` | `{ ok: boolean }` | **动态声明区域**:第三方模块在代码中动态创建自定义空间区域 | -| `area.unregisterArea` | `{ name: string }` | `{ ok: boolean }` | 销毁指定标识的动态区域 | -| `area.byName` | `{ name: string }` | `AreaDefinition \| null` | 按标识名检索区域定义与特性字典 | -| `area.byPoint` | `{ dimension: string, x: number, z: number, feature?: string }` | `AreaDefinition \| null` | 空间点查:检索命中坐标的区域,可选按包含指定 feature 过滤 | -| `area.listAreas` | `{ dimension?: string }` | `AreaDefinition[]` | 列出全部或指定维度内的区域清单 | - -### requires - -无。 - -## 7. Lifecycle notes - -- `afterWorldLoad`: **true**(移动侦测与周期 tick 须在世界完全实装后激活) -- **边界状态机与生命周期派发**: - 1. 内部维护 `playerCurrentAreas: Map>`; - 2. 周期调度或位置变动时执行 AABB 包含校验: - - 若玩家新进入某区域:触发该区域所有已注册特性的 `onEnter`; - - 若玩家离开某区域:触发对应特性的 `onLeave`; - - 玩家下线:对其实际身处的区域触发 `onLeave`,防止状态遗留(如创造/飞行残留); - 3. 周期触发所有活动区域的 `onTick`,供周期性扫描类特性执行。 - -## 8. Acceptance criteria - -- [ ] 核心零内置业务代码,所有空间规则完全通过 `AreaFeatureHandler` 插槽委托 -- [ ] 边界进出判定准确,`onEnter`、`onLeave`、`onTick` 派发无误 -- [ ] 玩家离线时正确触发离开清算,避免权限/模式泄漏 -- [ ] 未安装特性的声明声明式平稳容错,不抛出异常 -- [ ] provides 各项空间查询与插槽注册服务完整有效 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 三维 Y 轴高度裁剪、凸多边形空间索引与可视化选区工具 - -## 10. Legacy reference(仅参考) - -- `packages/area/`(架构重构为微内核插槽底座) diff --git a/docs/superpowers/specs/modules/chat-design.md b/docs/superpowers/specs/modules/chat-design.md deleted file mode 100644 index 1187cf49..00000000 --- a/docs/superpowers/specs/modules/chat-design.md +++ /dev/null @@ -1,113 +0,0 @@ -# chat — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 构建多频道聊天路由中心与**全平台唯一的聊天消息管道与插槽提供者(Chat Pipeline & Slot Provider)**。独占接管并取消原生的 `world.beforeEvents.chatSend`,向平台提供出站消息分发(`chat.send` / `chat.broadcast`)、前置消息拦截器插槽(`chat.registerInterceptor`,供 `qa` 等拦截作答或敏感词过滤)以及投递观察者插槽(`chat.onMessage`,供 `chat-sounds` 等匹配音效或留档);同时支持公共频道隔离分流、点对点私聊、空间坐标一键分享、玩家传送请求与节操红包分发,并通过标准化 service 对外暴露面板唤起能力供 `gui` 聚合调度。 - -**Non-goals:** -- 禁止下游模块私自裸监听原生 `world.beforeEvents.chatSend` 竞争拦截,全服聊天数据流统一经由本模块管道插槽派发 -- 严禁向外部导出内部 `MenuNavigator` 或 GUI 实现类,跨模块界面唤起统一经由 RPC service 服务完成 -- 杜绝移植旧版碎片化的界面文件树,全面基于现行 SDK UI 规范重构 -- 离线玩家皮肤像素烘焙(Skin Bake)与自定义字体 Glyph 生成属于独立工具链,不作为本模块运行时目标 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `chat` | -| npm | `@sfmc-bds/module-chat` | -| manifest.id | `chat` | -| configKey | `chat` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `["economy"]` | - -## 3. Player surface - -| 命令 | 权限节点 | 权限等级 | 行为摘要 | -|------|----------|----------|----------| -| `channel` / `ch` | `chat.use` | Member | 打开频道面板 / 切换 | -| `msg` | `chat.use` | Member | 私聊 | -| `lo` | `chat.use` | Member | 分享坐标 | -| `tp` | `chat.use` | Member | 传送邀请相关 | -| `hongbao` / `hb` | `chat.use` | Member | 红包 | - -另登记 `chat.admin` 为 Admin(等级 2),用于管理频道(创建/解散、禁言、设置慢速等)。 - -以 `!`/`!` 开头的消息留给其它系统(命令或前置拦截器),或经由拦截器插槽消费后不进入公屏频道。 - -### GUI(本模块内 MenuNavigator) - -频道面板、管理、设置、私聊、红包、创建/重命名、选人、发送/领取、邀请等;**通过 service 打开**(见 §6),不 `export class` 给 gui。 - -## 4. Data - -| 表名 | 归属 | 关键列 | -|------|------|--------| -| `sfmc_chat_channels` | 平台/本模块 | id, name, type, prefix, owner_id, allow_chat, slow_mode, is_broadcast | -| `sfmc_chat_messages` | 同上 | from_*, channel_id, type, content, attachment, created_at | -| `sfmc_chat_redpackets` | 同上 | 金额/份数/领取者 JSON、过期 | -| `sfmc_players` | 偏好字段 | active_channel, subscribed_channels | -| `sfmc_chat_avatars` | 本模块 defineTable | player_id, slot 0–255, skin_hash, dirty | - -## 5. Config - -`configKey: chat`;频道慢速/广播等可存表内。可选读取桥接设置(如 QQ channel id)若平台提供。 - -## 6. Services - -### provides - -| name | input | output | 语义 | -|------|-------|--------|------| -| `chat.openChannelPanel` | `{ playerId }` | `{ ok }` | 打开主频道 GUI | -| `chat.openRedPacketPanel` | `{ playerId }` | `{ ok }` | 红包 GUI | -| `chat.openPrivatePanel` | `{ playerId, targetId? }` | `{ ok }` | 私聊 GUI | -| `chat.send` | `{ channelId?, targetPlayerId?, senderId?, senderName?, content, type? }` | `{ ok, messageId? }` | 向指定频道或私聊目标发送结构化消息 | -| `chat.broadcast` | `{ content, prefix?, channelId? }` | `{ ok }` | 全服或指定频道系统级广播(供 qa、公告等调用) | -| `chat.registerInterceptor` | `{ id, priority?, handler }` | `{ ok }` | **进程内拦截插槽**:注册前置消息拦截器,若 handler 消费(返回 true)则阻断进入常规频道(供 qa 作答等) | -| `chat.onMessage` | `{ id, handler }` | `{ ok }` | **进程内观察插槽**:注册消息送达观察者,在合法消息分发至公屏时触发(供 chat-sounds 等音效/审计匹配) | - -(实现可增补只读查询服务;至少保证 gui 与下游插槽模块不依赖内部实现类。) - -### requires - -| name | 来自 | 用途 | -|------|------|------| -| `economy.account.credit` | [economy-design.md](./economy-design.md) | 红包领取入账 | -| `economy.account.debit` | 同上 | 发红包扣款 | - -发红包与抢红包遵循**两阶段幂等补偿机制**: -1. 生成全局业务幂等键(如 `hb_send_`、`hb_claim__`); -2. 先通过 `economy.account.debit` / `credit` 完成玩家计分板资金划转; -3. 划转成功后再提交本地 `sfmc_chat_redpackets` 记录变更。若本地更新失败,触发逆向补偿(原路退款 credit)或记录异常流水。**不依赖且不存在 `inTx` 经济服务**。 - -## 7. Lifecycle notes - -- `afterWorldLoad`: **false** -- `registerEvents`:**独占接管原生事件**。订阅 `world.beforeEvents.chatSend` 并统一设置 `event.cancel = true`,杜绝原生未受控广播。 -- **消息插槽管道(Chat Pipeline)处理序列**: - 1. **命令前缀排查**:以半角 `!` 或全角 `!` 开头的内容,若属于已注册命令则放行由命令引擎处理; - 2. **前置拦截插槽(Interceptors)**:执行按优先级排序的拦截器链(如 `qa` 竞答作答)。若拦截器判定命中并消费该消息,则中止后续常规频道路由; - 3. **频道与权限校验**:解析发信玩家当前活跃频道,校验禁言、冷却慢速与发送权限; - 4. **持久化**:消息存入 `sfmc_chat_messages` 留档; - 5. **投递观察者插槽(Observers)**:触发 `chat.onMessage` 观察者钩子(如 `chat-sounds` 关键字扫描、跨服转发); - 6. **客户端渲染播发**:构造富文本并通过 `Msg.raw` 分发至对应频道订阅者。 - -## 8. Acceptance criteria - -- [ ] 独占接管原生聊天并提供标准拦截器与观察者管道插槽 -- [ ] 频道路由、慢速、红包原子扣/入账、私聊/坐标/传送邀请可用 -- [ ] provides 面板服务与广播服务;**无** `@sfmc-bds/module-*` 依赖 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 皮肤图集 bake 流水线、glyph 资源包自动化 - -## 10. Legacy reference(仅参考) - -- `packages/chat/`(含旧 chat-gui 合并) diff --git a/docs/superpowers/specs/modules/chat-sounds-design.md b/docs/superpowers/specs/modules/chat-sounds-design.md deleted file mode 100644 index 1f7ab6b1..00000000 --- a/docs/superpowers/specs/modules/chat-sounds-design.md +++ /dev/null @@ -1,82 +0,0 @@ -# chat-sounds — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 挂接至 `chat` 模块的消息管道观察者插槽(Observer Slot),实时匹配聊天文本中的预设关键字,向全服广播对应的 Minecraft 原声音效;内置玩家维度的冷却防刷机制,增强聊天趣味性与互动感。 - -**Non-goals:** -- 首版不构建动态热词后台管理界面或复杂词典系统 -- **禁止私自裸监听原生 `world.beforeEvents.chatSend`**,全服聊天事件统一由 `chat` 模块独占并转化为标准消息管道 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `chat-sounds` | -| npm | `@sfmc-bds/module-chat-sounds` | -| manifest.id | `chat-sounds` | -| configKey | `chat_sounds` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `["chat"]` | - -## 3. Player surface - -纯插槽响应,无独立执行指令。接入 `chat.onMessage` 消息管道观察者插槽进行文本子串模式匹配。 - -## 4. Data - -纯内存维护冷却时间戳,无数据库表。 - -## 5. Config - -`configs/chat_sounds.json`: - -```json -{ - "cooldown_ticks": 200, - "rules": [ - { "keyword": "ciallo", "sound": "random.levelup", "volume": 1.0, "pitch": 1.0 }, - { "keyword": "baka", "sound": "mob.villager.no", "volume": 1.0, "pitch": 1.2 } - ] -} -``` - -未配置时自动回退采用内置预设词表与音效映射,默认冷却为 200 ticks(约 10 秒)。 - -## 6. Services - -### provides - -无。 - -### requires - -| name | 来自 | 用途 | -|------|------|------| -| `chat.onMessage` | [chat-design.md](./chat-design.md) | 订阅聊天管道中合法投递的消息文本与玩家上下文 | - -## 7. Lifecycle notes - -- `afterWorldLoad`: **false** -- `registerEvents`:调用 `chat.onMessage` 注册关键词匹配观察者;严禁直接订阅原生 `chatSend` -- 冷却防刷控制:对命中关键词的生存模式玩家记录冷却到期刻数;冷却期间仅发送聊天文本,不触发全服音效;创造模式玩家可豁免冷却直接触发。 - -## 8. Acceptance criteria - -- [ ] 正确挂接 `chat.onMessage` 管道插槽,不抢占原生聊天事件 -- [ ] 命中预设关键字即时向全服玩家播发目标音效 -- [ ] 玩家个人冷却机制严格生效,无刷屏音效污染 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 动态热词在线增删 GUI - -## 10. Legacy reference(仅参考) - -- `packages/chat-sounds/` - diff --git a/docs/superpowers/specs/modules/clean-design.md b/docs/superpowers/specs/modules/clean-design.md deleted file mode 100644 index cd6bce12..00000000 --- a/docs/superpowers/specs/modules/clean-design.md +++ /dev/null @@ -1,108 +0,0 @@ -# clean — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 构建全服与区域无主掉落物自动巡检与回收清理模块。挂接在 `area` 空间引擎上支持针对特定区域(如主城、公共广场、刷怪重灾区)定制掉落物清理策略,同时提供全服掉落物阈值防卡服预警、全服倒计时广播通知、定向将可回收物品存入指定公共回收箱(Recycle Bin)以及特指有害实体(如过量经验球、失控矿车)安全销毁;对外提供 OP 运维手动清理指令(`!clean`)与可远程调度的清理服务。 - -**Non-goals:** -- 不自建独立的空间边界几何判定,特定区域清理依赖上游 `area` 空间插槽 -- 首版不构建面向普通玩家的拾遗找回 GUI 竞价系统 -- 严禁向外部导出内部领域类,所有跨模块通信经由标准化 service 调度 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `clean` | -| npm | `@sfmc-bds/module-clean` | -| manifest.id | `clean` | -| configKey | `clean` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `["area"]` | - -## 3. Player surface - -| 命令 | 权限节点 | 权限等级 | 行为摘要 | -|------|----------|----------|----------| -| `clean` | `clean.admin` | OP (3) | 手动触发全服或指定区域掉落物扫描、倒计时广播与清理回收流程 | - -## 4. Data - -纯内存维护定时任务与预警状态机,无数据库持久化表。 - -## 5. Config - -`configs/clean.json`: - -```json -{ - "poll_interval_seconds": 60, - "item_threshold_warning": 200, - "countdown_seconds": 10, - "kill_list": [ - "minecraft:xp_orb", - "minecraft:tnt" - ], - "recycle_bin": { - "enabled": true, - "dimension": "minecraft:overworld", - "container_coords": [0, 64, 0], - "max_slots": 54 - } -} -``` - -- `poll_interval_seconds`:全服掉落物例行检查周期(默认 60 秒); -- `item_threshold_warning`:掉落物堆叠数量预警阈值,超过则触发自动清理倒计时; -- `countdown_seconds`:清理执行前的全服广播倒计时(默认 10 秒); -- `kill_list`:直接物理销毁而不存入回收箱的实体 ID 列表(如经验球、引爆物); -- `recycle_bin`:公共回收箱配置,可将地面积压的有价值物品移入指定物理箱子容器供玩家取用。 - -## 6. Services - -### provides - -| name | input | output | 语义 | -|------|-------|--------|------| -| `clean.trigger` | `{ force?: boolean, areaName?: string }` | `{ ok: boolean, cleanedCount: number }` | 供运维控制台或监控警报(monitor)远程调度执行清理 | - -### requires - -| name | 来自 | 用途 | -|------|------|------| -| `area.registerFeature` | [area-design.md](./area-design.md) | 向底层空间引擎注册 `clean` 特性生命周期处理器 | -| `area.byName`(可选) | 同上 | 获取特定目标区域空间范围以执行局域清理 | - -## 7. Lifecycle notes - -- `afterWorldLoad`: **true** -- `registerCommands`:注册 `!clean` 命令(校验 OP 权限,支持手动触发全局或指定区域掉落物扫描回收); -- `registerEvents`:调用 `area.registerFeature` 挂接 `clean` 空间特性生命周期处理器; -- `init`: - - **`onTick(ctx, params)`**:若区域声明了 `features.clean`,根据区域私有阈值独立扫描并清理该区域内的过量掉落物; - - **全服轮询扫描**:在世界加载完成后启动周期定时任务: - 1. 统计当前加载区块内的掉落物(`minecraft:item`)总量; - 2. 若超过 `item_threshold_warning`,广播倒计时提示全服玩家拾取地表贵重物品; - 3. 倒计时结束后,对处于 `kill_list` 中的实体调用 `kill()` 销毁;对常规物品实体尝试移入 `recycle_bin` 容器,溢出部分安全清除; - 4. 播发清理完成公告,报告清理掉落物总数。 - -## 8. Acceptance criteria - -- [ ] 成功调用 `area.registerFeature` 挂接 `clean` 特性 -- [ ] `!clean` 指令具备 OP 权限校验,执行后正确广播倒计时并回收掉落物 -- [ ] 掉落物超限时自动触发预警与清理,不卡顿主线程 -- [ ] 黑名单实体(经验球等)直接销毁,常规物品正确转移至回收箱 -- [ ] provides `clean.trigger` 服务有效 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 区分物品品质白名单保留、多维度独立回收站 - -## 10. Legacy reference(仅参考) - -- `packages/area/`(原 clean 掉落物清理子能力抽离重构) diff --git a/docs/superpowers/specs/modules/coop-design.md b/docs/superpowers/specs/modules/coop-design.md deleted file mode 100644 index f4b7c57c..00000000 --- a/docs/superpowers/specs/modules/coop-design.md +++ /dev/null @@ -1,95 +0,0 @@ -# coop — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 实现玩家合作社的全生命周期组织管理(创建、加入、转让、退出、解散)、独立公有金库资产存取,以及合作社活跃排行榜;提供紧凑高效的命令行与交互体验。 - -**Non-goals:** -- 首版不引入重量级全屏独立表单 GUI(由 `gui` 主菜单统一提供快捷提示与 `!coop` 交互指引) -- **暂不内置独立的合作社商店(`coopshop`)**:商品买卖与特权折扣应归并至全服主商店系统中,后续由合作社将主商店作为可选依赖进行挂接集成 -- **不自建孤立金库账本(遵循 DRY)**:合作社公账直接委托 `economy` 模块统一托管(命名空间为 `coop:`);`coop` 仅负责成员关系管理与提现权限鉴权,杜绝维护私有账本与流水 -- **不自建独立审计日志表(遵循 DRY)**:合作社内的重要治理操作(创建、转让、踢人、权限变更、解散)统一通过 `activity.record` 插槽上报至平台行为日志系统(`activity-log`),消除审计数据孤岛 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `coop` | -| npm | `@sfmc-bds/module-coop` | -| manifest.id | `coop` | -| configKey | `coop` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `["economy", "activity-log"]`(必选硬依赖) | - -## 3. Player surface - -| 命令 | 权限节点 | 权限等级 | 行为摘要 | -|------|----------|----------|----------| -| `coop` / `coop create\|join\|leave\|bank\|rank` | `coop.use` | Member | 合作社生命周期与金库/排行 | - -登记 `coop.admin` = Admin(等级 2)供合作社管理与干预操作。 - -## 4. Data - -| 表名 | 关键列 | -|------|--------| -| `sfmc_coops` | cid, name, owner_*, timestamps | -| `sfmc_coop_members` | cid, player_id, role(owner/admin/member), joined_at | -| `sfmc_coop_invites` | 邀请(若实现邀请流) | - -金库资产完全委托 `economy` 模块托管,审计日志统一委托 `activity-log` 模块归档;本模块仅维护核心领域实体关系,本地零多余冗余表。 - -## 5. Config - -首版可无文件配置;金库最大存取限额或规则限制等后续写入 `configs/coop.json`。 - -## 6. Services - -### provides - -| name | input | output | 语义 | -|------|-------|--------|------| -| `coop.byId` | `{ cid }` | coop 或 null | | -| `coop.list` | `{ limit? }` | `{ items[] }` | | -| `coop.byPlayer` | `{ playerId }` | 成员关系 / coop 摘要 | | - -**必须实际 `service.provide`,不得只写在 manifest。** - -### requires - -| name | 来自 | 用途 | -|------|------|------| -| `economy.account.get` | [economy-design.md](./economy-design.md) | 查询合作社公账(`coop:`)余额及个人钱包 | -| `economy.account.transfer` | 同上 | 金库注资存款(玩家 $\rightarrow$ `coop:`)与提现(`coop:` $\rightarrow$ 玩家) | -| `activity.record` | [activity-log-design.md](./activity-log-design.md) | 统一上报合作社治理审计日志(创建/踢人/转让/解散等) | - -**金库与审计治理机制:** -- **金库资产(托管至 `economy`)**:账户标识统一采用 `coop:` 作为合法机构公账;`coop` 模块仅负责业务门禁(入会检查、提现角色校验与额度限制),资金原子划转与防双花完全由 `economy.account.transfer` 承载; -- **审计追溯(托管至 `activity-log`)**:合作社内部重大变动(如解散、移交社长、成员踢出)统一调用 `activity.record`,以 `eventType: "coop.audit"` 异步写入全平台行为时序库。 - -## 7. Lifecycle notes - -- `afterWorldLoad`: **false** -- 创建合作社:本地事务中插入 `sfmc_coops` 与首任社长成员关系;调用 `activity.record` 上报建社事件;`coop:` 金库公账在首次转账时自动建分,初始余额为 0。 - -## 8. Acceptance criteria - -- [ ] `create/join/leave/bank/rank` 主路径可用 -- [ ] 存入与取出金库完全通过 `economy.account.transfer` 完成,无私有余额冗余表 -- [ ] 治理与权限变更操作通过 `activity.record` 异步入库,无私有审计冗余表 -- [ ] 非管理成员执行取款时正确被 `coop` 业务权限拦截 -- [ ] 三个 provide 服务可被调用 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 合作社专属商店(`coopshop`:剥离独立实现,未来合并入全服主商店作为可选依赖挂接) -- 完整图形 CoopGUI;邀请系统打磨 - -## 10. Legacy reference(仅参考) - -- `packages/coop/` diff --git a/docs/superpowers/specs/modules/daily-task-design.md b/docs/superpowers/specs/modules/daily-task-design.md deleted file mode 100644 index 66c3a4ed..00000000 --- a/docs/superpowers/specs/modules/daily-task-design.md +++ /dev/null @@ -1,41 +0,0 @@ -# daily-task — Design Spec - -> **Status:** deferred -> **Spec authority:** this file(搁置)。依赖的经济侧任务 API 已从 [economy-design.md](./economy-design.md) 移除。 - -## 1. Goal / Non-goals - -**Goal(未来):** 玩家侧每日任务 UI,调用**独立任务域**服务完成列表/提交;奖励经 `economy.account.credit`。 - -**当前决定(2026-09-04):** - -- **暂不实现**本模块;不进入开仓波次 C 的并行实现 -- economy **不再**提供 `economy.dailyTasks.list` / `submit` -- 旧 archive 中「任务表挂在经济库」方案废弃 - -**Non-goals(现在):** 不开仓、不发布 npm、不写 index 登记。 - -## 2. Identity(预留,实现时再用) - -| 字段 | 值 | -|------|-----| -| install id | `daily-task` | -| npm | `@sfmc-bds/module-daily-task` | -| manifest.id | `daily-task` | -| configKey | `daily_task` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `["economy"]`(仅账户 credit;任务数据自有或未来 `tasks` 模块) | - -## 3–8. Player / Data / Config / Services / Lifecycle / Acceptance - -**搁置。** 重新激活前须另开设计修订:明确任务权威存储(自有表 vs 新模块),再写完整章节与验收项。 - -## 9. Deferred - -- 本模块整体 -- 任务生成调度、管理 GUI - -## 10. Legacy reference(仅参考) - -- `packages/daily-task/`(旧:编排 `economy.dailyTasks.*`) diff --git a/docs/superpowers/specs/modules/data-backup-design.md b/docs/superpowers/specs/modules/data-backup-design.md deleted file mode 100644 index 5b723fa6..00000000 --- a/docs/superpowers/specs/modules/data-backup-design.md +++ /dev/null @@ -1,116 +0,0 @@ -# data-backup — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 定期将 Minecraft 世界全局参数(种子、难度、游戏规则、维度特征)、在线玩家核心元数据快照以及全服通用计分板(Scoreboards)快照持久化至平台底层引导表与快照表,提供高危计分板灾难恢复命令(`scoreboard restore`),为故障排查、容灾恢复与跨模块数据一致性提供基础事实底座。 - -**Non-goals:** -- 不承担 BDS 磁盘物理存档目录(LevelDB)的文件级归档备份(该能力明确归属于平台运维与 CLI 编排范畴) -- 不做实时逐分变更流式同步,统一采用周期代际快照策略 -- **严禁覆盖/篡改由专管业务模块托管的权威计分板**(如 `economy` 的 `sfmc_money` 权威余额) - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `data-backup` | -| npm | `@sfmc-bds/module-data-backup` | -| manifest.id | `data-backup` | -| configKey | `data_backup` | -| enabledByDefault | true | -| canDisable | **false**(平台基础设施,强制保持激活) | -| manifest.requires | `[]` | - -## 3. Player surface - -| 命令 | 权限节点 | 权限等级 | 行为摘要 | -|------|----------|----------|----------| -| `scoreboard restore` | `scoreboard.restore` | OP (3) | 从数据库最新一致性快照重建缺失的 Objective 与分数(高危灾难恢复操作) | - -## 4. Data - -使用平台级公共表与快照表(在 manifest 中声明 `db:read|write` 权限): - -| 表名 | 语义 | 关键列 | -|------|------|--------| -| `sfmc_players` | 玩家基础信息、进服时间与元数据快照 upsert | id, name, xuid, last_online, ... | -| `sfmc_world` | 世界种子、生成属性、难度、游戏规则(gamerules JSON)及环境快照 | id, seed, difficulty, gamerules, ... | -| `sfmc_scoreboards` | 全服通用计分板代际快照(换代更新或带 snapshot_id,杜绝无界追加) | snapshot_id, objective, participant, score, updated_at | - -每次计分板备份必须**可还原为单一一致快照**(先换代/清空旧代再批量插入,或 restore 时仅定位最新 `snapshot_id`),杜绝旧版读全历史冗余行的缺陷。 - -## 5. Config - -`configs/data_backup.json`: - -```json -{ - "world_interval_ticks": 600, - "scoreboard": { - "enabled": true, - "interval_ticks": 6000, - "ignore_objectives": [ - "sfmc_money" - ] - } -} -``` - -- `world_interval_ticks`:世界状态与玩家快照同步周期(默认 600 ticks / 30秒); -- `scoreboard.interval_ticks`:计分板全量一致性快照周期(默认 6000 ticks / 5分钟); -- `scoreboard.ignore_objectives`:**核心保护名单**。默认排除 `sfmc_money` 等由专管业务模块拥有权威性的 Objective;备份与 restore 时均跳过此类计分板,严禁覆盖经济权威余额并破坏流水对账一致性。 - -## 6. Services - -### provides - -| name | input | output | 语义 | -|------|-------|--------|------| -| `backup.createSnapshot` | `{ scope?: "all" \| "world" \| "scoreboard" }` | `{ ok: boolean, snapshotId: string }` | 供运维或自动化脚本手动触发全量/局域一致性快照刷盘 | -| `backup.restoreScoreboard` | `{ objective?: string, snapshotId?: string }` | `{ ok: boolean, restoredCount: number }` | 程序化触发从快照恢复通用计分板(跳过 ignore_objectives) | -| `backup.getScoreboardSnapshot` | `{ objective?: string, participant?: string, snapshotId?: string }` | `{ snapshotId: string, scores: Array<{ objective: string, participant: string, score: number }> }` | **离线计分板快照检索**:供排行榜看板、外部后台等只读查询已归档的通用计分板数据 | -| `backup.getPlayerSnapshot` | `{ playerId: string }` | `{ player: Record \| null }` | **离线玩家元数据检索**:查询 DB 中持久化的玩家最后已知信息与在线状态 | -| `backup.getWorldSnapshot` | `{}` | `{ world: Record \| null }` | **世界环境快照检索**:读取 DB 中记录的世界种子、难度与游戏规则快照 | - -**架构职责与实时性边界说明:** -- **实时权威业务(如钱包余额)**:由 `economy` 模块通过引擎原生计分板内存提供微秒级响应,防范网络延迟与双花漏洞;业务消费方必须调用 `economy.account.get`,严禁越级穿透读取 `data-backup` 离线代际快照; -- **离线历史与全服归档(如等级榜、离线玩家、世界环境参数)**:统一调用本模块 `backup.*` 只读服务,无需直接拼装 SQL 查库。 - -### requires - -无。 - -## 7. Lifecycle notes - -- `afterWorldLoad`: **true**(必须在世界与实体完全装载后方可读取原生属性与计分板) -- **同步机制与生命周期序列**: - 1. 模块 `init` 阶段执行一次世界元数据与计分板全量快照; - 2. 每 600 ticks 刷新在线玩家与世界 gamerules 状态; - 3. 监听 `playerSpawn`(首次进服),触发玩家元数据快照 upsert 写入; - 4. 每 6000 ticks 执行计分板换代备份(跳过 `ignore_objectives` 名单); - 5. **`scoreboard restore` 灾难恢复流程**: - - 查询 DB 最新一代 `sfmc_scoreboards` 快照; - - 过滤掉 `ignore_objectives` 中的受保护计分板; - - 重建游戏内缺失的 Objective; - - 对参与者赋值(在线玩家通过实体句柄,离线/虚拟参与者通过原生字符串标识); - 6. 停机 `cleanup` 阶段执行终态同步刷盘。 - -## 8. Acceptance criteria - -- [ ] 模块默认激活且不可被管理员指令禁用(`canDisable: false` 契约对齐) -- [ ] 世界参数、玩家元数据与计分板快照周期持久化成功 -- [ ] 计分板备份与 `scoreboard restore` 往返可用,数据完整重建 -- [ ] 严格排除 `sfmc_money` 等业务权威 Objective,不篡改玩家资金与流水 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 差异增量备份、导出离线快照压缩包 - -## 10. Legacy reference(仅参考) - -- `packages/data-backup/`(基础世界与玩家数据快照) -- `packages/scoreboard-sync/`(计分板同步与还原整合归并) diff --git a/docs/superpowers/specs/modules/economy-design.md b/docs/superpowers/specs/modules/economy-design.md deleted file mode 100644 index cde80e9b..00000000 --- a/docs/superpowers/specs/modules/economy-design.md +++ /dev/null @@ -1,197 +0,0 @@ -# economy — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 以**计分板为权威余额存储**,经统一 `economy.account.*` 服务读写;数据库做**实时同步留档**(快照 + 流水),并提供**通用统计查询接口**(供看板/运维消费,不内置白皮书等业务广播)。 - -**Non-goals:** - -- **不做**每日任务(`economy.dailyTasks.*` 删除;见 [daily-task-design.md](./daily-task-design.md) 搁置说明) -- **不**把月度白皮书、定时全服广播做成经济模块业务 -- 不移植旧 db-server 账本为唯一真相、不移植双命名 payload -- 不提供玩家命令面(由 `gui` / `chat` 等消费) -- 不 npm 依赖其它业务模块 -- 他模块**禁止**直接改计分板经济 objective、禁止直连经济归档表 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `economy` | -| npm | `@sfmc-bds/module-economy` | -| manifest.id | `economy` | -| configKey | `economy` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `[]` | - -## 3. Player surface - -### Commands - -无。 - -### GUI - -无。展示可用 SDK `Money` 缓存,底层仍只调 `economy.account.get`(读计分板权威值)。 - -## 4. Architecture - -```mermaid -flowchart LR - callers[chat_land_gui_qa_coop] - svc[economy.account_services] - sb[Scoreboard_objective] - db[(Archive_tables)] - stats[economy.stats.query] - callers --> svc - svc --> sb - svc --> db - stats --> db -``` - -| 层 | 角色 | -|----|------| -| **计分板** | **唯一权威余额**。每位玩家一个 score;credit/debit/transfer 先(或原子语义上)改分 | -| **数据库** | **实时留档**:每次变更写流水;账户快照 upsert;供对账、审计、`stats.query` | -| **服务** | 唯一对外突变/查询入口;消费方不得碰 objective 名或归档表 | - -### 计分板约定与账户抽象 - -- Objective id / 显示名由配置给出(默认建议 id `sfmc_money`,显示「节操」) -- **参与者账户标识(`accountId`)**: - - **个人玩家**:玩家唯一标识(如 `playerId` 或 `player:`);在线时绑定到 `Player` 实体 - - **组织/公账实体**:支持命名空间字符串(例如合作社公账 `coop:`、城镇 `town:` 等),依托 Minecraft SAPI 计分板原生支持的假名参与者(Fake Player / Scoreboard Identity)统一核算 - - **单向无环依赖原则**:`economy` 属于核心基础提供层(Wave A),**绝对不反向依赖任何具体业务模块(`manifest.requires: []`)**;经济系统仅将组织账户视作合法账户标识字符串,不感知合作社等业务实体的领域逻辑 -- 余额为**非负整数**;无记录视为 `0`(首次变动时自动建分) -- 其他业务模块严格禁止绕过服务直连底层 Objective 或归档表 - -### 数据库留档(本模块 `defineTable` 或平台表 + 本模块权限) - -| 表名 | 语义 | -|------|------| -| `sfmc_economy_accounts` | 快照:`account_id` PK(兼容原 `player_id`),`account_name_snapshot`,`balance`(与计分板对齐),`account_type`(player/org),`updated_at` | -| `sfmc_economy_transactions` | 流水:`id`,`type`(credit\|debit\|transfer),`actor_id`,`source_account_id`,`target_account_id`,`amount`,`balance_before`/`balance_after`,`reason`,`reference_*`,`idempotency_key?`,`created_at` | - -**同步策略:** - -1. 校验金额 / 幂等键(命中则重放归档结果,**不再改分**) -2. 读当前分 → 计算新分 → **写计分板** -3. **立即** upsert 快照 + insert 流水(同一逻辑操作内;DB 失败则记错误并进入重试队列,**不得静默丢流水**) -4. 返回变更结果(含 `balance` = 计分板新值) - -冷启动 / 周期(可选):扫描 objective 与快照对账,差异以**计分板为准**回写快照,并打审计日志。 - -货币展示名:**节操**。 - -## 5. Config - -`configs/economy.json`: - -```json -{ - "objectiveId": "sfmc_money", - "objectiveDisplay": "节操", - "unitName": "节操", - "reconcileIntervalTicks": 12000 -} -``` - -缺省用上表默认值。 - -## 6. Services - -### provides - -#### 账户(业务消费方使用) - -| name | input | output | 语义 | -|------|-------|--------|------| -| `economy.account.get` | `{ accountId, accountName? }`(兼容 `{ playerId }`) | `{ accountId, balance, accountName? }` | **读计分板**;支持个人与组织公账,可附带刷新名称快照 | -| `economy.account.credit` | `{ accountId, amount, reason?, actorId?, idempotencyKey?, referenceType?, referenceId? }` | `{ transactionId, balance, replayed? }` | 加分 + 留档(支持个人与组织账户) | -| `economy.account.debit` | 同 credit | 同上或 `insufficient_funds` | 减分;余额不足失败且不改分 | -| `economy.account.transfer` | `{ fromAccountId, toAccountId, amount, actorId?, reason?, idempotencyKey? }` | 变更结果 | 账户间转账(天然支持:玩家↔玩家转账、玩家↔组织公账如合作社金库存取款);双方计分板更新 + 留存流水 | - -**跨模块调用与事务契约:** -- 计分板为内存与原生引擎实体,**不参与**且物理上无法支持 SQLite `db.tx` / `inTx` 事务。 -- **不存在 `inTx` 经济服务**;**禁止**再造假 `db.tx` recorder。 -- 跨模块业务消费方(如 `land`、`chat` 红包、`coop`)与经济交互统一遵循 **两阶段幂等补偿模式**: - 1. 消费方生成全局唯一业务幂等键 `idempotencyKey`(如 `land_buy_`、`hb_send_`); - 2. 消费方先调用 `economy.account.debit` / `credit`(传入 `idempotencyKey` 与 `referenceType`/`referenceId`); - 3. 经济变更成功后,消费方再提交本地数据库表变更;若本地写入发生致命故障,由消费方调用逆向操作实施补偿(如退款 credit)或保留错误现场待审计修复。网络超时重试时,相同 `idempotencyKey` 将重放原流水结果,**绝不发生双花/重复扣款**。 -- 不提供 `dailyTasks.*`。 - -#### 统计(通用查询,非业务) - -| name | input | output | 语义 | -|------|-------|--------|------| -| `economy.stats.query` | 见下 | 见下 | 只读归档聚合;**不**发广播、**不**改余额 | - -**`economy.stats.query` input(规范信封):** - -```ts -{ - /** 指标名列表,实现至少支持下列 metric */ - metrics: Array< - | "supply" // 当前快照余额合计 - | "active_accounts" // balance>0 账户数 - | "tx_count" // 流水条数 - | "volume_credit" // credit 金额合计 - | "volume_debit" // debit 金额合计 - | "volume_transfer" // transfer 金额合计 - >; - /** 闭区间,Unix ms;省略则按 metric 语义用「当前」或「全部历史」 */ - from?: number; - to?: number; - /** 可选分桶:省略则返回单个汇总 */ - groupBy?: "day" | "month"; -} -``` - -**output:** - -```ts -{ - generatedAt: number; - rows: Array<{ - bucket?: string; // groupBy 时如 "2026-09" / "2026-09-04" - values: Record; // metric → 值 - }>; -} -``` - -消费方(看板、QQ 桥、管理 GUI)自行决定如何展示;经济模块**不**内置「月初白皮书」定时广播。 - -### requires - -无。 - -## 7. Lifecycle notes - -- 冷启动 only -- `afterWorldLoad`: **true**(需 world scoreboard) -- `init`:确保 objective 存在;定义/确保归档表;可选对账 interval;注册 services -- `cleanup`:清 interval / 重试队列 flush - -## 8. Acceptance criteria - -- [ ] 余额权威在计分板;`get/credit/debit/transfer` 行为与 §6 一致 -- [ ] 每次成功变更均有流水 + 快照;幂等键不双花 -- [ ] 他模块无法(规范 + 权限)直接写经济 objective / 经济表 -- [ ] **无** `economy.dailyTasks.*`;**无**定时白皮书业务 -- [ ] `economy.stats.query` 支持所列 metrics,可按 day/month 分桶 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 每日任务(独立模块 + 未来任务服务,不挂在 economy) -- 物价指数表 / 商店定价 -- QQ/外部支付桥 -- 多币种 objective - -## 10. Legacy reference(仅参考) - -- `sfmc-modules` `archive/monorepo-packages` → `packages/economy/`(旧:db 权威账本 + dailyTasks + monthly 广播 —— **全部废弃为行为参考,勿照搬**) diff --git a/docs/superpowers/specs/modules/fly-area-design.md b/docs/superpowers/specs/modules/fly-area-design.md deleted file mode 100644 index bd7c5052..00000000 --- a/docs/superpowers/specs/modules/fly-area-design.md +++ /dev/null @@ -1,96 +0,0 @@ -# fly-area — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 作为挂接在 `area` 空间引擎上的声明式特性组件(`fly`),为进入指定空间区域的生存模式玩家赋予临时飞行许可(`mayfly`),并在离开区域时平稳撤销飞行并施加短时缓降(Slow Falling)安全缓冲,杜绝玩家因边界撤销飞行导致的高空摔伤;提供平滑无感知的区域飞行体验。 - -**Non-goals:** -- 不自建独立空间边界检测逻辑,完全接入上游 `area` 空间插槽 -- 不提供面向全图或付费买飞的全局飞行管理(该能力由其它专属插件承载) -- 严禁向外部导出内部实现类 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `fly-area` | -| npm | `@sfmc-bds/module-fly-area` | -| manifest.id | `fly-area` | -| configKey | `fly_area` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `["area"]` | - -## 3. Player surface - -纯空间插槽驱动,无面向普通玩家的交互命令。 - -登记权限节点: - -| 权限节点 | 权限等级 | 行为摘要 | -|----------|----------|----------| -| `fly_area.use` | Any (0) | 飞行能力标记(进入飞行区域时由系统动态赋予) | - -## 4. Data - -纯内存维护当前处于飞行赋权状态的在线玩家集合,无数据库持久化表。 - -## 5. Config - -`configs/fly_area.json`: - -```json -{ - "slow_falling_duration_ticks": 100, - "slow_falling_amplifier": 0, - "actionbar_notify": true -} -``` - -- `slow_falling_duration_ticks`:离开飞行区域时赋予的缓降效果时长(默认 100 ticks / 5秒),确保悬空玩家平稳落地; -- `slow_falling_amplifier`:缓降效果等级(默认 0); -- `actionbar_notify`:是否在玩家进出飞行区时于 actionbar 显示状态提示。 - -## 6. Services - -### provides - -无。 - -### requires - -| name | 来自 | 用途 | -|------|------|------| -| `area.registerFeature` | [area-design.md](./area-design.md) | 向底层空间引擎注册 `fly` 特性生命周期处理器 | - -## 7. Lifecycle notes - -- `afterWorldLoad`: **true** -- `registerEvents`:调用 `area.registerFeature` 挂接 `fly` 特性生命周期处理器: - - **`onEnter(player, ctx, params)`**: - - 若玩家处于生存模式,动态开启原生飞行能力(`mayfly: true`)并登记 `fly_area.use` 临时标记; - - 若配置开启 `actionbar_notify`,显示入区飞行提示。 - - **`onLeave(player, ctx, params)`**: - - 若玩家具有 `fly_area.use` 标记,立即关闭 `mayfly` 飞行能力; - - 若离开时玩家处于悬空滞空状态,自动施加指定时长的 `minecraft:slow_falling` 缓降效果,防止跌落暴毙; - - 若配置开启 `actionbar_notify`,显示离区缓降提示。 -- 玩家下线保障:依赖 `area` 引擎派发的下线退出清算,安全移除临时飞行状态,杜绝卡出区域离线带飞。 - -## 8. Acceptance criteria - -- [ ] 成功调用 `area.registerFeature` 挂接 `fly` 特性 -- [ ] 生存模式玩家进入声明 `features.fly` 的区域自动获得飞行能力 -- [ ] 离开飞行区时立即取消飞行能力,悬空玩家获得缓降保护且无摔伤 -- [ ] 离开区域及下线重登无残留飞行能力漏洞 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 区分创造模式/生存模式单独配置允许飞行白名单 - -## 10. Legacy reference(仅参考) - -- `packages/area/`(原 fly 子能力抽离重构) diff --git a/docs/superpowers/specs/modules/gamemode-area-design.md b/docs/superpowers/specs/modules/gamemode-area-design.md deleted file mode 100644 index 5600a12a..00000000 --- a/docs/superpowers/specs/modules/gamemode-area-design.md +++ /dev/null @@ -1,117 +0,0 @@ -# gamemode-area — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 作为挂接在 `area` 空间引擎上的声明式特性组件(`gamemode`),提供生存与创造模式沙箱控制与状态机隔离。玩家进入创造沙箱时快照保存生存背包并切换创造模式,阻断封禁物品放置与跨区夹带创造物品外流;离开创造区或进入强制生存区时,还原生存背包并纠正游戏模式;对外暴露创造链开关服务供 `gui` 聚合调度。 - -**Non-goals:** -- 不自建独立的空间边界侦测循环,进出判定与区域管理严格依赖上游 `area` 空间插槽 -- 不实现跨维度的分布式背包转移,聚焦于单服内空间区域的状态隔离 -- 严禁向外部导出内部领域类,所有跨模块通信经由标准化 service 调度 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `gamemode-area` | -| npm | `@sfmc-bds/module-gamemode-area` | -| manifest.id | `gamemode-area` | -| configKey | `gamemode_area` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `["area", "inventory-switcher"]` | - -## 3. Player surface - -纯空间插槽驱动,无面向普通玩家的手动输入命令。 - -登记权限节点: - -| 权限节点 | 权限等级 | 行为摘要 | -|----------|----------|----------| -| `gamemode_area.bypass` | OP (3) | 允许在受控区域外保留创造或旁观模式豁免 | -| `gamemode_area.place_banned` | Admin (2) | 允许在创造区内放置黑名单封禁物品 | - -## 4. Data - -**零私有数据表**。玩家的生存与创造物品快照统一委托给上游 `inventory-switcher` 模块(存储在 `sfmc_inventories` 表中,指定槽位 `gamemode_survival_backup`)。 - -## 5. Config - -`configs/gamemode_area.json`: - -```json -{ - "creative_chain_enabled": true, - "enforce_survival_outside": true, - "banned_items": [ - "minecraft:bedrock", - "minecraft:barrier", - "minecraft:structure_block", - "minecraft:tnt" - ] -} -``` - -- `creative_chain_enabled`:创造/生存联动链总开关;若设为 false,创造沙箱与校正逻辑安全空转; -- `enforce_survival_outside`:是否在所有创造区外强制将非豁免玩家纠正为生存模式; -- `banned_items`:创造区内默认禁放与禁用的危险物品 ID 清单。 - -## 6. Services - -### provides - -| name | input | output | 语义 | -|------|-------|--------|------| -| `gamemode.setCreativeChainEnabled` | `{ enabled: boolean }` | `{ ok: boolean }` | 供 `gui` 管理面板启停创造/生存模式联动链 | -| `gamemode.isCreativeChainEnabled` | `{}` | `{ enabled: boolean }` | 查询当前创造模式联动链开关状态 | - -### requires - -| name | 来自 | 用途 | -|------|------|------| -| `area.registerFeature` | [area-design.md](./area-design.md) | 向底层空间引擎注册 `gamemode` 特性生命周期处理器 | -| `inventory.save` | [inventory-switcher-design.md](./inventory-switcher-design.md) | 快照并暂存玩家当前生存背包至隔离槽位 | -| `inventory.restore` | 同上 | 从隔离槽位完整恢复玩家生存背包 | -| `inventory.clear` | 同上 | 清空创造背包,彻底阻断创造物品带出 | -| `inventory.has` | 同上 | 检查是否存在待恢复的生存背包快照 | - -## 7. Lifecycle notes - -- `afterWorldLoad`: **true** -- `registerEvents`:调用 `area.registerFeature` 挂接 `gamemode` 特性处理器;订阅方块放置事件进行创造区禁放拦截: - - **`onEnter(player, ctx, params)`**: - - 若区域参数 `params.mode === "creative"`: - 1. 调用 `inventory.save({ playerId: player.id, slotKey: "gamemode_survival_backup", clearCurrent: true })` 将生存物品安全快照并清空手头背包; - 2. 切换为 `GameMode.creative` 并发送进入提示; - - 若区域参数 `params.mode === "survival"` 或在校正区外且玩家处于创造模式: - 1. 校验 `gamemode_area.bypass` 权限;无豁免则调用 `inventory.clear({ playerId: player.id })` 清空创造物品,再调用 `inventory.restore({ playerId: player.id, slotKey: "gamemode_survival_backup", clearAfter: true })`,纠正为生存模式。 - - **`onLeave(player, ctx, params)`**: - - 若离开创造区: - 1. 调用 `inventory.clear({ playerId: player.id })` 彻底销毁创造物品; - 2. 调用 `inventory.restore({ playerId: player.id, slotKey: "gamemode_survival_backup", clearAfter: true })` 还原生存背包; - 3. 切换回 `GameMode.survival`。 -- **宕机与掉线安全防御**: - - 玩家在创造区下线后重登:`playerSpawn` 时若玩家不在创造区且拥有 `gamemode_survival_backup` 快照,自动触发安全清理与恢复流程,杜绝物品丢失与卡创造漏洞。 - -## 8. Acceptance criteria - -- [ ] 成功调用 `area.registerFeature` 挂接 `gamemode` 特性 -- [ ] 依赖 `inventory-switcher` 完成背包快照与还原,模块自身零冗余数据表 -- [ ] 进入创造区快照背包并切创造;出创造区清空创造物品并还原生存背包 -- [ ] 创造区内封禁物品放置有效阻断 -- [ ] 创造区外非豁免创造模式自动纠正为生存模式 -- [ ] 异常断线重连能正确恢复背包,无丢物卡创造漏洞 -- [ ] `gamemode.setCreativeChainEnabled` 供 `gui` 调用有效 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 多套独立创造背包方案、按领地权限细分允许创造名单 - -## 10. Legacy reference(仅参考) - -- `packages/area/`(原 creative & survival 组合子功能重构抽离) diff --git a/docs/superpowers/specs/modules/gui-design.md b/docs/superpowers/specs/modules/gui-design.md deleted file mode 100644 index 57a22463..00000000 --- a/docs/superpowers/specs/modules/gui-design.md +++ /dev/null @@ -1,321 +0,0 @@ -# gui — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 构建**全平台数据驱动交互引擎与无感导航中枢(Data-Driven UI Engine & Navigation Hub)**。 -- 深度集成 `@sfmc-bds/sdk/sapi/runtime` 中的 `MenuNavigator` 与 `@minecraft/server-ui` 全新数据驱动 UI(DDUI)特性; -- 为全服提供统一交互主菜单(`!menu`)、管理员控制台(`!admin`)以及右键快捷唤起道具; -- **双模菜单插槽(Dual-Mode Navigation Slots)**: - - **无感子路由模式(Seamless SPA Section)**:通过 `build(page, nav)` 将业务子页面直接编织进同一个 `CustomForm`,基于 `ObservableBoolean` 实现 0 毫秒零闪烁页面切换、自动面包屑导航(`titleObs`)与受控返回按钮(`backVis`); - - **独立动作模式(Standalone Handler)**:通过 `handler(player)` 支持常规独立动作或唤起非 DDUI 模态表单; -- **响应式动态状态栏(Live Reactive Dashboard)**:在主菜单顶部利用 `ObservableString` 动态呈现玩家实时个人资产、在线时长与服务器 TPS,在玩家眼前实时跳动更新,表单无需关开刷新; -- **单页内联状态反馈(Inline FormStatus)**:集成 `FormStatus`(成功/失败/加载中内联提示),彻底消除打断操作的二次弹窗; -- **表单防踩与繁忙安全重试(UserBusy Safe Queue)**:内置智能轮询重试管道,彻底根绝基岩版在玩家走动、跳跃或界面冲突时“吞表单”的顽疾; -- **微模态二次确认标准服务(`gui.confirm`)**:为全平台提供统一、规范的异步二次确认对话框服务。 - -**Non-goals:** -- **严禁在 `gui` 内部硬编码任何具体业务逻辑**(领地、钱包、聊天、合作社等界面均由对应模块启动时声明式挂载); -- **严禁强制依赖任何外部业务模块**,`manifest.requires` 严格保持 **`[]`**(实时大盘感知到 `economy` 等服务不存在时优雅降级隱去对应信息); -- 不做侵入式 HUD 物理覆层,严格立足并最大化发挥 SAPI 数据驱动表单的标准能力。 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `gui` | -| npm | `@sfmc-bds/module-gui` | -| manifest.id | `gui` | -| configKey | `gui` | -| enabledByDefault | true | -| canDisable | **false**(全服交互与导航底座,强制保持激活) | -| manifest.requires | `[]` | - -## 3. Player surface - -| 命令 | 权限节点 | 权限等级 | 行为摘要 | -|------|----------|----------|----------| -| `menu`(别名 `cd`) | `menu.use` | Any (0) | 打开动态聚合的 SPA 无感主菜单 | -| `admin`(或 `sfmcadmin`) | `gui.admin` | OP (3) | 打开管理员控制台(多模块运维聚合) | - -### 物理快捷道具唤起(Right-click Shortcut Item) - -- 玩家手持配置的快捷道具(默认 `minecraft:clock`)**右键或长按**时,直接唤起主菜单; -- 具备防丢/防放置阻断保护,移动端与手柄玩家可一键呼出,大幅降低打字门槛。 - -## 4. Data - -纯内存维护动态导航项与管理项注册表(`Map`)以及活跃玩家的 `MenuNavigator` 实例句柄,无数据库持久化表。 - -## 5. Config - -`configs/gui.json`: - -```json -{ - "menu_title": "§l§eSFMC 综合服务中心", - "admin_title": "§l§cSFMC 管理员控制台", - "empty_placeholder": "当前暂无可用服务", - "show_live_dashboard": true, - "enable_shortcut_item": true, - "shortcut_item": { - "type": "minecraft:clock", - "name": "§e§l快捷导航 §7[右键/长按打开]", - "lore": ["§7随时呼出服务器综合服务菜单"], - "give_on_first_join": false, - "slot": 8 - }, - "busy_retry_ticks": 10, - "busy_max_retries": 16 -} -``` - -## 6. Services & 契约定义 - -### 6.1 菜单注册项契约 (MenuItemDefinition) - -```ts -import { Player } from "@minecraft/server"; -import { Page, MenuNavigator } from "@sfmc-bds/sdk/sapi/runtime"; - -export interface MenuItemDefinition { - readonly id: string; - /** 按钮主标题,支持静态字符串或动态计算函数 */ - readonly title: string | ((player: Player) => string | Promise); - /** 排序权重(升序) */ - readonly order?: number; - /** 图标路径(支持资源包相对路径或原生纹理路径) */ - readonly icon?: string; - /** 功能分类标识,例如 "general" | "economy" | "world" | "admin" */ - readonly category?: string; - /** 权限节点限制(未授权玩家自动隐藏) */ - readonly permission?: string; - /** 动态徽章 / 副标题(例如 "[余额: 1,250]" 或 "[2条待办]") */ - readonly badge?: (player: Player) => string | Promise; - /** - * 模式 A(推荐):无感子路由页面构建器。 - * 页面直接编织进同一个 CustomForm,利用 ObservableBoolean 0ms 零闪烁切换, - * 自动获得面包屑导航、统一返回按钮与内联 FormStatus。 - */ - readonly build?: (page: Page, nav: MenuNavigator) => void | Promise; - /** - * 模式 B:独立动作回调。 - * 用于执行独立操作、触发外部命令或唤起非 DDUI 模态表单。 - */ - readonly handler?: (player: Player) => void; -} -``` - -### 6.2 页面构建器接口规范 (Page) - -SDK 运行时的 `Page` 接口已完整对齐 `@minecraft/server-ui` 的最新 DDUI 控件矩阵: - -```ts -export interface Page { - /** 按钮:支持绑定 disabled(ObservableBoolean)、tooltip 与点击回调 */ - button(label: string | ObservableString, onClick: () => void, options?: ButtonOptions): this; - /** 文本标签:只读展示 */ - label(text: string | ObservableString | UIRawMessage): this; - /** 标题行:大号加粗章节标题 */ - header(text: string | ObservableString | UIRawMessage): this; - /** 图像:支持资源包内图片相对路径与宽度控制 */ - image(src: string | ObservableString, pack: string | ObservableString, options?: ImageOptions): this; - /** 水平分割线:划分卡片逻辑区域 */ - divider(options?: DividerOptions): this; - /** 垂直留白:制造呼吸感间距 */ - spacer(options?: SpacingOptions): this; - /** 文本输入框:支持占位符、小字说明与 disabled 响应式受控 */ - textField(label: string | ObservableString, text: ObservableString, options?: TextFieldOptions): this; - /** 开关切换:支持小字说明与 disabled 受控 */ - toggle(label: string | ObservableString, toggled: ObservableBoolean, options?: ToggleOptions): this; - /** 下拉选择框:持有选中项索引与选项列表 */ - dropdown( - label: string | ObservableString, - value: ObservableNumber, - items: DropdownItemData[], - options?: DropdownOptions - ): this; - /** 滑块控件:持有连续数值,支持 step 步长与 min/max 范围 */ - slider( - label: string | ObservableString, - value: ObservableNumber, - min: number | ObservableNumber, - max: number | ObservableNumber, - options?: SliderOptions - ): this; -} -``` - -### 6.3 provides - -| name | input | output | 语义 | -|------|-------|--------|------| -| `gui.registerMenuItem` | `MenuItemDefinition` | `{ ok: boolean }` | **主菜单插槽**:注册玩家主菜单入口项(支持 SPA 子页面与独立动作) | -| `gui.unregisterMenuItem` | `{ id: string }` | `{ ok: boolean }` | 注销指定主菜单项 | -| `gui.registerAdminItem` | `MenuItemDefinition` | `{ ok: boolean }` | **管理后台插槽**:向管理员控制台注册运维管理项 | -| `gui.openMainMenu` | `{ playerId: string }` | `{ ok: boolean }` | 为指定玩家构建并打开 SPA 无感主菜单 | -| `gui.openAdminPanel` | `{ playerId: string }` | `{ ok: boolean }` | 为指定管理员打开运维控制台 | -| `gui.confirm` | `{ playerId: string, title: string, body: string, confirmText?: string, cancelText?: string }` | `Promise` | **全平台二次确认**:标准异步确认对话框(内置繁忙重试) | -| `gui.showForm` | `{ playerId: string, form: any, maxRetries?: number }` | `Promise` | **防吞表单执行器**:统一代理表单展现并处理 `UserBusy` 繁忙队列 | - -### 6.4 requires - -无(零外部强依赖。运行时弱协同探测 `economy`、`online-time` 与 `monitor` 服务用于丰富顶部 Live Dashboard,若不存在则自然降级隐藏对应信息段)。 - ---- - -## 7. DDUI 数据驱动表单五大高级设计范式 (Design Patterns) - -平台各业务模块在构建界面时,应优先采用以下现代 DDUI 设计范式,彻底摒弃旧版基岩版插件的粗糙交互: - -### 范式 1:响应式表单实时防呆与按钮置灰 (Validation & Disabled) - -通过将按钮的 `options.disabled` 绑定至 `ObservableBoolean`,在输入不合规或玩家余额不足时实时置灰禁用,杜绝非法提交: - -```ts -// 玩家在输入框打字或改变开关时,计算属性自动生效 -const isValidName = obsBool(false); -const nameInput = obsStr(""); - -// 当名字合法且金币充足时,提交按钮自动激活变亮 -page.textField("转账目标玩家", nameInput, { - placeholder: "输入玩家完整名称", - description: "请仔细核对玩家名称,转账不可撤销" -}); - -page.button("确认转账", handleTransfer, { - disabled: isInvalidObs, // 响应式布尔值:无效时按钮为灰色不可点击 - tooltip: "输入有效玩家名称与金额后即可点击" -}); -``` - -### 范式 2:多控件联动与实时计价计算器 (Live Dynamic Calculator) - -拖动滑块或切换下拉项时,通过 Observable 监听实时重新计算总价与费用标签,打造现代电商 App 般的体验: - -```ts -// 领地租赁:面积下拉 + 天数滑块 = 实时总价联动 -const daysObs = obsNum(7); -const priceText = obsStr("§e预估费用: §a700 节操"); - -page.slider("租赁天数", daysObs, 1, 30, { - step: 1, - description: "每日基准租金为 100 节操" -}); - -page.label(priceText); // 拖动滑块时,价格标签数字当场跳动更新! - -daysObs.subscribe((days) => { - const cost = days * 100; - priceText.setData(`§e预估费用: §a${cost} 节操 §7(${days}天)`); - canSubmit.setData(myMoney >= cost); -}); -``` - -### 范式 3:视觉呼吸感与品牌排版 (Hero Banner & Aesthetic Spacing) - -利用 `image()` 挂载宣传画,配合 `spacer()` 和 `divider()` 消除界面逼仄感,构建清晰视觉层级: - -```ts -// 1. 顶部品牌海报 -page.image("textures/ui/sfmc_hero.png", "sfmc-modules-rp", { width: 320 }); -page.spacer(); // 垂直呼吸感留白 - -// 2. 章节大标题 -page.header("§6我的领地资产"); -page.divider(); // 分割线 - -// 3. 业务操作区 -page.button("📍 创建新领地", () => nav.go("land_create")); -page.button("📜 领地成员与授权", () => nav.go("land_members")); -page.spacer(); -``` - -### 范式 4:单页内联状态流与微模态确认 (Inline Status & Micro-Confirm) - -彻底消灭弹窗中断,通过 `FormStatus` 原位展现操作结果,危险操作直接在表单内微模态滑入: - -```ts -const status = new FormStatus(page); // 绑定原位状态行 - -page.button("购买飞行权限", async () => { - await nav.runTask(status, async () => { - // 异步执行扣费与权限授予 - await service.call("fly.purchase", { playerId: player.id }); - status.ok("飞行权限激活成功!有效期 2 小时。"); - }, "购买失败,请检查节操余额。"); -}); - -// 危险操作:表单内原地滑入确认页 -// DDUI 按钮不支持 § 格式码;危险级别通过标题、正文和确认步骤表达。 -page.button("放弃领地", () => { - nav.confirm( - "§c⚠ 放弃领地确认", - "放弃后领地保护立即失效,所有方块将对全服开放。确定继续吗?", - async () => { - await service.call("land.abandon", { landId }); - nav.replace("land_list"); // 替换为列表页 - } - ); -}); -``` - -### 范式 5:原生物品多语言与计分板投影 (Native I18n & RawText Projection) - -利用原版 `rawtext` 实现客户端语言自适应与计分板免轮询投射: - -```ts -page.label({ - rawtext: [ - { text: "§7主手手持道具: §e" }, - { translate: "item.diamond_sword.name" }, // 客户端自动依据系统语言翻译 - { text: "\n§7当前赛季排行: §6第 " }, - { score: { name: "*", objective: "sfmc_rank" } }, // 原生计分板直投 - { text: " 名" } - ] -}); -``` - ---- - -## 8. Lifecycle notes & 核心运行时机制 - -- `afterWorldLoad`: **true**(需在世界就绪后挂载 `itemUse` 快捷道具右键监听); -- **SPA 单页装配与零闪烁路由机制(MenuNavigator Assembly)**: - 1. 玩家唤起主菜单时,`gui` 为其创建 `MenuNavigator(player)` 实例; - 2. 根页面 `root` 头部挂载 `ObservableString` 驱动的 **Live Dashboard**: - - 查询玩家货币(`economy.account.balance`)$\to$ 绑定响应式变量; - - 查询在线时长(`onlinetime.byPlayer`)$\to$ 绑定响应式变量; - - 查询刻速状态(`tps.status`)$\to$ 绑定响应式变量; - 3. 遍历所有注册项,进行权限过滤与动态副标题(`badge`)解析; - 4. 对于声明了 `build(page, nav)` 的项: - - 在同一 `CustomForm` 内部通过 `nav.section(id, title, build)` 注册子页面; - - 根页面放置对应按钮,点击后直接调用 `nav.go(id)`; - 5. 对于仅声明 `handler(player)` 的项: - - 点击后调用 `nav.leave(() => handler(player))`; - 6. 页面切换仅触发 `ObservableBoolean` 变更,整个浏览流程**无黑屏、无加载延迟、面包屑与返回键全自动托管**。 -- **防吞表单安全重试管道(UserBusy Handling)**: - - 所有表单展现统一经过繁忙重试循环:若捕获 `DataDrivenScreenClosedReason.UserBusy`,每 `busy_retry_ticks` 重新探测唤起,最多重试 `busy_max_retries` 次(约 8 秒),并发送温馨提示,彻底解决基岩版被怪打退或跳跃时弹不出表单的问题。 - -## 9. Acceptance criteria - -- [ ] 核心零业务硬编码,双模插槽(`build` SPA 子页面与 `handler` 独立动作)运转正常 -- [ ] 顶部 Live Dashboard 响应式绑定实时数据,外部服务未启用时优雅降级 -- [ ] 页面层级跳转零闪烁,顶部面包屑与返回按钮随历史栈自适应显隐 -- [ ] 内联 `FormStatus` 状态提示与 `nav.confirm` 微模态确认生效 -- [ ] `Page` 完整暴露 `image`、`spacer`、`divider`、`button` (含 `disabled`/`tooltip`) 等 DDUI 全套控件 -- [ ] `gui.confirm` 与 `gui.showForm` 具备可靠的 `UserBusy` 繁忙重试机制,杜绝吞表单 -- [ ] 手持快捷时钟道具右键/长按成功唤起主菜单 -- [ ] `manifest.requires` 严格为 `[]` -- [ ] typecheck / lint / test 通过 - -## 10. Deferred - -- 自定义资源包 GUI 纹理主题与调色板动态换肤 -- 玩家自定义个人高频快捷入口收藏夹 - -## 11. Legacy reference(仅参考) - -- `packages/gui/`(全面重构为微内核数据驱动交互引擎) diff --git a/docs/superpowers/specs/modules/inventory-switcher-design.md b/docs/superpowers/specs/modules/inventory-switcher-design.md deleted file mode 100644 index e7027609..00000000 --- a/docs/superpowers/specs/modules/inventory-switcher-design.md +++ /dev/null @@ -1,98 +0,0 @@ -# inventory-switcher — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 构建**全平台通用的玩家背包快照、隔离还原与多槽位切换服务提供者(Inventory Switching & Snapshot Provider)**。将玩家全部物品栏(主背包、快捷栏、盔甲栏、副手槽位)结构化序列化并持久化至数据库;对外暴露标准化的 service 接口,供上层业务模块(如 `gamemode-area` 创造沙箱、副本活动等)按需调度,彻底解耦具体业务逻辑与底层背包序列化细节。 - -**Non-goals:** -- **严禁私自监听原生游戏模式变化(`playerGameModeChange`)**:模式与区域判定一律由上层业务模块(如 `gamemode-area`)主导,本模块纯粹作为受控的被动底层服务 -- **彻底废除在主世界生成物理双箱与木告示牌记录的旧版遗留方案**,统一采用高性能数据库结构化存储 -- 严禁向外部导出内部实体序列化类,所有跨模块操作严格经由标准化 service 调度 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `inventory-switcher` | -| npm | `@sfmc-bds/module-inventory-switcher` | -| manifest.id | `inventory-switcher` | -| configKey | `inventory_switcher` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `[]` | - -## 3. Player surface - -纯受控服务底座,无面向普通玩家的手动输入命令。 - -管理面交互(灾备兜底): - -| 命令 | 权限节点 | 权限等级 | 行为摘要 | -|------|----------|----------|----------| -| `inv restore ` | `inv.admin` | OP (3) | 紧急情况下从指定槽位快照手动为玩家恢复背包 | - -## 4. Data - -| 表名 | 归属 | 关键列 | 用途 | -|------|------|--------|------| -| `sfmc_inventories` | 本模块 defineTable | player_id, slot_key, items_data, updated_at | 结构化持久化存储玩家各槽位背包(含盔甲与副手)JSON 快照 | - -联合唯一索引:`(player_id, slot_key)`。 - -## 5. Config - -`configs/inventory_switcher.json`: - -```json -{ - "max_slots_per_player": 5, - "save_xp_level": false -} -``` - -- `max_slots_per_player`:每个玩家允许持久化的最大槽位数量上限(默认 5 套); -- `save_xp_level`:是否在保存背包时同步快照并还原玩家的经验值与等级。 - -## 6. Services - -### provides - -| name | input | output | 语义 | -|------|-------|--------|------| -| `inventory.save` | `{ playerId: string, slotKey?: string, clearCurrent?: boolean }` | `{ ok: boolean }` | 将玩家当前物品栏保存至指定槽位(缺省为 `"default"`),可选保存后清空当前背包 | -| `inventory.restore` | `{ playerId: string, slotKey?: string, clearAfter?: boolean }` | `{ ok: boolean }` | 从指定槽位读出快照并完整恢复至玩家物品栏,可选恢复后从数据库删除该记录 | -| `inventory.switch` | `{ playerId: string, fromSlotKey: string, toSlotKey: string }` | `{ ok: boolean }` | 原子置换两个槽位(将当前背包存入 `fromSlotKey`,并载入 `toSlotKey` 内容) | -| `inventory.clear` | `{ playerId: string, backupSlotKey?: string }` | `{ ok: boolean }` | 清空玩家当前全部物品栏,可选在清空前自动备份至 `backupSlotKey` | -| `inventory.has` | `{ playerId: string, slotKey: string }` | `{ exists: boolean }` | 检查指定玩家在特定槽位是否存在已保存的背包快照 | - -### requires - -无。 - -## 7. Lifecycle notes - -- `afterWorldLoad`: **true**(需世界实装以访问玩家实体物品组件) -- **序列化与恢复契约**: - 1. 完整覆盖 36 格主背包容器、4 格装备栏(Head / Chest / Legs / Feet)与副手(Offhand); - 2. 精确记录 `typeId`、`amount`、耐久(damage)、显示名(nameTag)、自定义 lore 与附魔属性; - 3. 还原时执行清空校验,先完整清空对应槽位再逐一填充,杜绝物品重叠或卡出复制; -- 纯被动架构:无任何常驻轮询定时器,所有行为完全由上游模块通过 RPC service 调用触发。 - -## 8. Acceptance criteria - -- [ ] 核心零模式监听与零物理箱子生成,定位纯净的背包服务 -- [ ] `inventory.save` / `restore` / `switch` / `clear` / `has` 完整注册且往返序列化不丢属性 -- [ ] 盔甲与副手栏位与主背包一同原子更新 -- [ ] 数据库表 `sfmc_inventories` 字段定义符合规范,支持多槽位隔离 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 离线玩家背包远程修改、支持跨服 Redis 暂存 - -## 10. Legacy reference(仅参考) - -- `packages/inventory-switcher/`(废弃物理双箱方案,重构为微服务数据持久化) diff --git a/docs/superpowers/specs/modules/land-design.md b/docs/superpowers/specs/modules/land-design.md deleted file mode 100644 index bc428c5b..00000000 --- a/docs/superpowers/specs/modules/land-design.md +++ /dev/null @@ -1,217 +0,0 @@ -# land — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only(旧包已从磁盘删除)。 - -## 1. Goal / Non-goals - -**Goal:** 构建**全服土地租赁契约(Leasehold Contract / 只租不卖)与新生代开拓庄园生态(Settlement & Territory Engine)**。 -- **确立「只租不卖」根本法则**:彻底废除永久买断制,以定期土地租赁契约消除死服僵尸鬼城与地皮垄断;零买断首付门槛,按日租金起租,支持长租折扣(月租9折、季租8折); -- **物理守护基石(Land Totem)与高精度选点**:放置基石即签订契约起租,右键通过 DDUI 拖动滑块实时扩建;为建筑党保留 3D 精确选点; -- **扩建平滑补差机制**:中途扩建时,仅需补缴剩余有效租期内的日租金差额,前期投入零浪费; -- **双轨充能续租与 7 天休眠保护**:支持节操币或原版矿物(煤/铁/金/绿宝石/钻石)双轨充能续约;到期未续租进入 **7 天欠租休眠保护期**(建筑封存受保护,增益与商业暂停),7天内补交即刻复苏,超期彻底终止契约并自然回收地皮; -- **原生级 3D 硬件加速线框(DebugDrawer)**:深度基于 `@minecraft/debug-utilities`,渲染原版结构方块级的彩色立体线框(`DebugBox`)与 3D 空间浮空名牌(`DebugText`),零实体、零卡顿、完美贴合方块边缘; -- **领地增益插槽(Land Perks)**:提供防爆、抑怪、舒适度回血、防雷防火等环境增益,空间托管至 `area` 微内核; -- **商业地标与门票造血**:支持开启公开地标(`/land tp`),门票收入秒级全额划转至主人钱包或组织公账(`coop:`),配套庄园留言簿与点赞榜; -- **平台中枢服务深度闭环**:空间边界由 `area` 空间微内核托管,治理审计全量归入 `activity-log`,资金两阶段结算通达 `economy`,交互控制台无缝嵌入 `gui` DDUI SPA 路由。 - -**Non-goals:** -- **严禁开放永久买断私产**,所有地皮一律以租赁契约(`lease_until`)形式存续; -- **严禁自建私有审计日志表**(废除旧 `sfmc_land_audit_logs`),变动与治理事件全量委托平台 `activity-log`; -- **严禁自建玩家坐标移动轮询循环**,空间索引与进出边界事件统一由 `area` 微内核空间引擎派发; -- 领地重叠校验必须对当前维度**全部既有有效契约领地**进行 AABB 空间碰撞检测; -- 数据表全量采用 `sfmc_lands*` 统一命名空间。 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `land` | -| npm | `@sfmc-bds/module-land` | -| manifest.id | `land` | -| configKey | `land` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `["economy", "activity-log"]` | - -## 3. Player surface & 视觉交互 - -| 权限 | 等级 | 用途 | -|------|------|------| -| `land.use` / `land.gui.use` | Any (0) | 领地起租、管理、扩建、续约与控制台交互 | -| `land.tp` | Any (0) | 传送至已公开的领地地标(支付门票) | -| `land.admin` / `land.gui.admin` | OP (3) | 管理员强行介入、公共广场配置与违规回收 | - -### 3.1 租赁契约与圈地方式 - -1. **物理基石辐射起租(Totem Core Lease,新手与移动端首选)**: - - 放置特殊的【守护基石】方块(新手进服赠送初级基石,商城或合成可得); - - 放置瞬间在基石中心生成初始领地(默认垂直通天模式 $Y = -64 \sim 320$,初始水平半径 16 格),弹出 DDUI 租赁契约面板; - - 自由选择起租天数(默认 7 天),世界中立刻浮现青绿色 `DebugBox` 结构线框; - - 右键基石呼出 DDUI 控制台:拖动【扩建滑块】,世界中的 `DebugBox` 线框随着手指实时放大缩小,当场计算剩余天数的租金补差; - - 拆除基石(仅限主人)或在菜单中解除契约,领地即时安全注销。 -2. **高精度 3D 选点框选(Precision Box Lease,建筑党专属)**: - - 手持圈地工具敲选两点,世界中实时连出明黄色 `DebugBox` 预览虚框; - - 输入 `!land lease ` 签订租赁契约。 - -### 3.2 硬件级 3D 视觉高亮体系(DebugDrawer) - -基于 `@minecraft/debug-utilities`(若未开启则优雅降级为粒子边界): -- **所有权色彩区分**: - - 本人庄园:安全青绿 `{ red: 0.2, green: 1.0, blue: 0.4, alpha: 0.6 }` - - 他人领地:警示警戒红 `{ red: 1.0, green: 0.2, blue: 0.2, alpha: 0.5 }` - - 公共广场/集市:明亮天空蓝 `{ red: 0.2, green: 0.7, blue: 1.0, alpha: 0.5 }` - - 选点/扩建预览:高亮金黄 `{ red: 1.0, green: 0.8, blue: 0.2, alpha: 0.8 }` -- **空间 3D 悬浮名牌(DebugText)**: - - 在基石上方或入口上空投射 3D 浮空字:`§a[史蒂夫的庄园 Lv.2] §7(契约剩余 28天)`; -- **智能视距生命周期**: - - 玩家进入领地周边 16 格或手持管理工具时自动挂载渲染,离开视野后自动调用 `removeShape()` 释放。 - -## 4. Data - -全量数据表统一使用 `sfmc_lands*` 命名空间: - -| 表名 | 关键列 / 语义 | -|------|----------------| -| `sfmc_lands` | `id` VARCHAR(64) PK,`owner_id`(`player:` 或 `coop:`),`name`,`dimension`,`min_x`,`min_y`,`min_z`,`max_x`,`max_y`,`max_z`,`core_x`,`core_y`,`core_z`,`level`(1~5),`status`(`active`\|`dormant`\|`terminated`),`daily_rent`(当前每日租金),`lease_until`(Unix ms,租赁截止期),`grace_until`(Unix ms,休眠宽限截止期),`ticket_price`,`is_public`,`likes_count`,`version`,`created_at`,`updated_at` | -| `sfmc_land_members` | `land_id`,`player_id`,`role`(owner/admin/member/guest),`permissions_json`,`updated_at` | -| `sfmc_land_perks` | `land_id`,`perk_id`(noboom/peace/fireproof/heal/nofall),`enabled`(BOOLEAN),`settings_json` | -| `sfmc_land_guestbook` | `id` PK,`land_id`,`visitor_id`,`visitor_name`,`message`,`is_like`,`created_at` | -| `sfmc_land_operations` | `request_id` PK,`operation_type`(lease/renew/expand/ticket/disband),`status`,`response_json`,`created_at` | - -## 5. Config - -`configs/land.json`: - -```json -{ - "base_daily_rent": 10, - "rent_per_100_blocks": 2, - "max_lands_per_player": 5, - "land_count_multiplier": [1.0, 1.5, 2.0, 3.0, 3.0], - "long_term_discounts": { - "30": 0.9, - "90": 0.8 - }, - "grace_period_days": 7, - "default_claim_mode": "vertical", - "totem": { - "item_type": "minecraft:respawn_anchor", - "item_name": "§e§l守护基石 §7[放置即起租]", - "initial_radius": 16, - "max_level": 5, - "level_radius": [16, 24, 32, 48, 64] - }, - "mineral_rates": { - "minecraft:coal": 0.5, - "minecraft:iron_ingot": 2, - "minecraft:gold_ingot": 5, - "minecraft:emerald": 20, - "minecraft:diamond": 30 - }, - "plaza": { - "name": "自由广场", - "welcome": "欢迎来到公共安全广场", - "dimension": "minecraft:overworld", - "range": [0, 64, 0, 100, 128, 100] - }, - "default_permissions": { - "allow_place": true, - "allow_destroy": true, - "attack_entity": true, - "open_container": true, - "use_door": true, - "use_button": true, - "use_redstone": true, - "interact_entity": true, - "pickup_item": true - } -} -``` - -## 6. Services - -### provides - -| name | input | output | 语义 | -|------|-------|--------|------| -| `land.byId` / `land.byPos` | `{ id: string }` / `{ dimension, x, y, z }` | 领地契约详情 | 按 ID 或三维坐标检索领地 | -| `land.listByOwner` | `{ ownerId: string, limit?, offset? }` | `Land[]` | 查询名下租赁领地(支持个人与组织公账) | -| `land.listMembers` | `{ landId: string }` | `Member[]` | 领地授权成员列表 | -| `land.getPlayerRole` | `{ landId: string, playerId: string }` | `{ role, permissions }` | 查询玩家在目标领地的权限掩码 | -| `land.validateBox` | `{ dimension, min, max, excludeLandId? }` | `{ valid, dailyRent, conflict? }` | AABB 碰撞检测与日租金计算 | -| `land.createLease` | `{ playerId: string, dimension, min, max, days: number, isTotem?: boolean }` | `{ ok, landId, leaseUntil }` | 签订新领地租赁契约(扣除首期租金) | -| `land.renewLease` | `{ landId: string, days?: number, mineralItem?: any }` | `{ ok, newExpiry }` | 续签契约(支持货币扣除与矿物投入双轨) | -| `land.expandLease` | `{ landId: string, newMin, newMax }` | `{ ok, feeDiff }` | 扩建领地(按剩余天数平滑补交租金差额) | -| `land.terminateLease`| `{ landId: string, playerId: string }` | `{ ok }` | 主动解除契约(注销领地、归还基石) | -| `land.teleport` | `{ playerId: string, landId: string }` | `{ ok, feePaid }` | 传送至公开地标并扣除门票转入主人账户 | -| `land.leaseStatus` | `{ landId: string }` | `{ remainingDays, dailyRent, status, isGracePeriod }` | 查询租赁有效倒计时与契约状态 | -| `land.getPerks` / `setPerk` | `{ landId: string, perkId?: string, enabled?: boolean }` | `{ perks }` | 查询与配置庄园环境增益开关 | -| `land.guestbook.list` / `sign` | `{ landId: string, message?: string, isLike?: boolean }` | `{ ok }` | 庄园留言板与点赞操作 | -| `land.auditLog` | `{ landId: string }` | `ActivityRecord[]` | 委托 `activity-log` 检索领地历史治理审计日志 | -| `land.openMainMenu` | `{ playerId: string }` | `{ ok }` | 为玩家弹出领地 SPA 租赁控制台(DDUI) | - -### requires - -| name | 来自 | 用途 | -|------|------|------| -| `economy.account.get` | [economy-design.md](./economy-design.md) | 校验起租、续约、扩建补差与门票访客余额(支持个人与公账) | -| `economy.account.debit` / `credit` | 同上 | 租金扣除、门票秒级划转、扩建扣费 | -| `activity.record` / `activity.query` | [activity-log-design.md](./activity-log-design.md) | **治理审计与日志检索**:归档起租续费等事件,并支撑 `land.auditLog` 查询 | -| `area.registerArea` | [area-design.md](./area-design.md) | **空间引擎注册**:契约生效时挂接 Area 实例,托管边界与增益 | -| `area.unregisterArea` | 同上 | 契约终止时从空间引擎同步销毁区域 | -| `gui.registerMenuItem` | [gui-design.md](./gui-design.md) | **导航中枢自挂接**:启动期自主向主菜单注入 SPA 庄园入口 | - -## 7. Lifecycle notes & 核心租赁运作机制 - -- `afterWorldLoad`: **true**(需在世界加载后挂载方块放置、交互监听与 `debugDrawer` 形状更新); -- **起租与 DDUI 实时联动装配**: - 1. 玩家放置【守护基石】$\to$ 碰撞检测 $\to$ 呼出 DDUI 契约签署面板; - 2. 滑块选择租期天数(如 30 天,享 9 折优惠),价格标签实时打表联动; - 3. 确认后调用 `economy.account.debit` 扣除首期租金,记录两阶段操作流水; - 4. 插入 `sfmc_lands`(`status: 'active'`, `lease_until = now + days * 86400000`); - 5. 调用 `area.registerArea` 挂接空间微内核; - 6. 通过 `debugDrawer.addShape(new DebugBox(...))` 在世界中渲染安全青绿结构线框; - 7. 调用 `activity.record` 归档 `land.lease_start` 审计事件。 -- **扩建平滑补差算法(Expansion Differential Calculation)**: - - 玩家在控制台拖动面积滑块时,世界中的 `DebugBox` 线框随手指滑动实时放大缩小; - - 剩余有效天数 $D_{\text{rem}} = \max(0, \lfloor (\text{lease\_until} - \text{now}) / 86400000 \rfloor)$; - - 扩建差价 $= (R_{\text{new}} - R_{\text{old}}) \times D_{\text{rem}}$; - - 确认后仅扣除这笔差价,面积向外扩展,`lease_until` 保持完全不变。 -- **欠租与 7 天休眠保护状态机(Lease Expiration State Machine)**: - - **定时扫描调度**(每小时周期任务): - - 若 `now > lease_until` 且 `status == 'active'`: - - 状态流转为 `status: 'dormant'`,设定 `grace_until = now + 7 * 86400000`; - - 暂停领地内环境增益与公共门票传送,向主人推送欠租离线/在线预警; - - **核心保证:建筑结构依然处于不可破坏保护之中**,防止退服悲剧; - - 若玩家在宽限期内补交租金(`renewLease`): - - 状态瞬间恢复为 `status: 'active'`,`lease_until = now + renewedDays * 86400000`,增益重新激活; - - 若 `now > grace_until` 且仍未补交租金: - - 状态流转为 `status: 'terminated'`; - - 契约正式终止!调用 `area.unregisterArea` 销毁空间保护,清除 `DebugBox`; - - 地皮重新归入大自然或允许全服新玩家拓荒,彻底杜绝死服僵尸鬼城! - - 调用 `activity.record` 归档 `land.lease_terminated`。 -- **门票结算流水线(两阶段无双花)**: - - 访客发起 `/land tp ` $\to$ `economy.account.debit` 扣减访客门票 $\to$ `economy.account.credit` 全额划入领地主人(个人或合作社公账)$\to$ 安全传送并派发迎宾 Title。 - -## 8. Acceptance criteria - -- [ ] 彻底确立土地租赁契约制,无永久买断地皮 -- [ ] 放置守护基石自动生成领地并签署契约,支持 DDUI 滑块实时扩建补差 -- [ ] `@minecraft/debug-utilities` 的 `DebugBox` / `DebugText` 硬件级线框与 3D 浮空名牌渲染正常(不支持时优雅降级为粒子边界) -- [ ] 欠租 7 天休眠保护机制有效(建筑受保护,增益暂停,7天内续租满血复活,超时终止契约自然回收) -- [ ] 续租充能支持节操币与原版矿物(煤/铁/金/绿宝石/钻石)双轨折算 -- [ ] 门票商业系统生效,访客传送费秒级入账主人钱包或组织公账(`coop:`) -- [ ] 彻底移除旧 `sfmc_land_audit_logs` 表,治理与变更全量由 `activity.record` 审计 -- [ ] 领地空间全面托管至 `area` 微内核,进出 Actionbar 提示与环境增益派发生效 -- [ ] 启动期成功向 `gui` 挂载 SPA 租赁控制台,无需 `gui` 内部硬编码 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 领地子摊位转租系统(Sub-lease) -- 违约清退时原主人室内贵重容器打包进“云端失物招领箱” - -## 10. Legacy reference(仅参考) - -- `packages/land/`(彻底废除旧版买断制,全面重构为「只租不卖」现代土地租赁契约生态) diff --git a/docs/superpowers/specs/modules/monitor-design.md b/docs/superpowers/specs/modules/monitor-design.md deleted file mode 100644 index bcab9b98..00000000 --- a/docs/superpowers/specs/modules/monitor-design.md +++ /dev/null @@ -1,108 +0,0 @@ -# monitor — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 作为服务器性能与运行健康中枢: -1. 高频(逐 tick)内存环形采样服务端每秒游戏刻数(TPS),向全服玩家与控制台提供实时刻速查询指令 `!tps`,并对外暴露标准化的 TPS 状态服务; -2. 周期性(如每 30 秒)采集服务端宏观负载:统计主世界、下界与末地各维度的活跃实体数量,估算在线玩家视距覆盖的区块负载; -3. 提供管理员综合性能查询命令 `!monitor`,并将多维指标批量持久化至监控时序表,为服务器性能分析与瓶颈诊断提供基础数据。 - -**Non-goals:** -- 首版不构建复杂的 Web 前端图表大屏(数据沉淀至 SQLite 供运维与拓展分析) -- 不做卡顿实体的自动清理干预(卡顿清理职责解耦并归属于 `clean` 模块) -- 不引入重型第三方监控探针,完全基于原生 SAPI 与本地数据服务 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `monitor` | -| npm | `@sfmc-bds/module-monitor` | -| manifest.id | `monitor` | -| configKey | `monitor` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `[]` | - -## 3. Player surface - -| 命令 | 权限节点 | 权限等级 | 行为摘要 | -|------|----------|----------|----------| -| `tps` | `tps.see` | Any | 向执行玩家(或控制台)输出当前服务端 TPS 运行状况(带健康色阶) | -| `monitor` | `monitor.admin` | Admin | 向管理员输出当前服务器综合负载(TPS、各维度活跃实体数、在线玩家区块总负载估算) | - -## 4. Data - -- **纯内存环形缓冲区**:容量为 100 的环形采样缓冲(逐 tick 记录墙钟毫秒戳),提供微秒级无锁的实时 TPS 计算,冷启动未满时保底 20.00。 -- **持久化监控表**: - -| 表名 | 关键列 | -|------|--------| -| `sfmc_monitor_metrics` | `id` PK,`recorded_at`(Unix ms),`tps`(REAL),`dimension`(TEXT),`entity_count`(INT) | -| `sfmc_monitor_player_chunks` | `id` PK,`player_id`,`player_name`,`dimension`,`pos_x`,`pos_y`,`pos_z`,`render_distance`,`chunk_estimate`,`updated_at` | - -## 5. Config - -`configs/monitor.json`: - -```json -{ - "sample_interval_ticks": 600, - "history_retention_hours": 72 -} -``` - -默认周期采样与持久化间隔为 600 ticks(约 30 秒),历史时序数据保留 72 小时。 - -## 6. Services - -### provides - -| name | input | output | 语义 | -|------|-------|--------|------| -| `tps.current` | `{}` | `number` | 当前实时 TPS 浮点值,保留 2 位小数,区间 `[0.00, 20.00]` | -| `tps.status` | `{}` | `{ text: string, tps: number, grade: "green"\|"yellow"\|"gold"\|"red" }` | `text` 为带色阶串(供聊天);`grade` 供 gui/监控结构化渲染 | -| `monitor.metrics` | `{}` | `{ tps: number, grade: string, entities: Record, totalLoadedChunks: number }` | 当前全服综合性能快照,供管理后台或跨模块消费 | - -### requires - -无(零外部依赖,作为平台底层基石监控能力)。 - -### 采样规则与健康色阶 - -- **采样机制**:依托 `system.runInterval` 逐 tick 记录墙钟时间戳,维护容量为 100 的环形采样缓冲区;冷启动采样未满时默认返回 20.00; -- **健康色阶定义**: - - 优秀($\ge 19.5$):`green`(聊天颜色 `§a`) - - 正常($\ge 15.0$):`yellow`(聊天颜色 `§e`) - - 负载($\ge 10.0$):`gold`(聊天颜色 `§6`) - - 卡顿($< 10.0$):`red`(聊天颜色 `§c`) - -## 7. Lifecycle notes - -- `afterWorldLoad`: **true**(维度实体分布与玩家视距区块估算需在世界加载后执行;内存 TPS 环形采样可在 `init` 提前启动,`cleanup` 时注销计时器句柄) -- **后台采集管道**: - 1. 实时流水线:逐 tick 采样时钟增量更新环形缓冲; - 2. 周期流水线(`sample_interval_ticks`):读取实时 TPS $\rightarrow$ 遍历各维度计算实体总数 $\rightarrow$ 统计玩家所在区块与视距估算 $\rightarrow$ 在本地 `db.tx` 批量事务写入快照。 - -## 8. Acceptance criteria - -- [ ] `!tps` / `!tps` 任意玩家可用,输出带色阶的刻速 -- [ ] `!monitor` / `!monitor` OP 玩家可用,输出综合性能大盘 -- [ ] 对外稳定提供 `tps.current`、`tps.status`、`monitor.metrics` 服务,向后完全兼容已有调用契约 -- [ ] 零外部依赖(`manifest.requires: []`) -- [ ] 多维度实体计数与玩家区块负载采样准确,持续批量落库 -- [ ] 数据库表名统一规范为 `sfmc_monitor_*` -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 前端图表可视化 / 导出 CSV -- 异常掉刻告警推送(如配合 `qq-bridge` 或群推送) - -## 10. Legacy reference(仅参考) - -- `packages/monitor/` -- `packages/tps/` diff --git a/docs/superpowers/specs/modules/online-time-design.md b/docs/superpowers/specs/modules/online-time-design.md deleted file mode 100644 index 9460d617..00000000 --- a/docs/superpowers/specs/modules/online-time-design.md +++ /dev/null @@ -1,163 +0,0 @@ -# online-time — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 基于**进服时间戳打点(Session Timestamp)+ 增量心跳结转(Heartbeat Flush)**的无锁高效模型,精准统计并持久化玩家当前会话(Session)、当日(Today)、当月(Month)及历史累计总在线秒数(Total);向玩家提供极速实时的格式化时长查询指令 `!onlinetime`,并向平台生态提供标准化的单人时长快照(`onlinetime.byPlayer`)与全服在线时长排行榜(`onlinetime.top`)跨模块服务。 - -**Non-goals:** -- 不在模块内部绑定在线发钱、经济奖励或签到奖励逻辑(解耦设计:由经济或签到消费模块调用本服务执行奖励) -- 不直接依赖且不扫描行为审计流水日志(避免日志 30 天自动清理淘汰导致历史时长蒸发、崩服数据异常以及 $O(N)$ 复杂时序匹配开销) -- 不假设复杂的跨服或跨区分片时钟同步 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `online-time` | -| npm | `@sfmc-bds/module-online-time` | -| manifest.id | `online-time` | -| configKey | `online_time` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `[]` | - -## 3. Player surface - -| 命令 | 权限节点 | 权限等级 | 行为摘要 | -|------|----------|----------|----------| -| `onlinetime`(别名 `onlineTime`) | `onlinetime.see` | Any | 仅限玩家执行:实时格式化展示本次会话/今日/本月/累计总时长 | - -- 控制台执行时友好拦截并提示仅限玩家终端使用; -- **GUI 声明式集成**:若检测到 `gui.registerMenuItem` 服务已注册,自动向玩家主菜单挂载 `🕒 在线统计` 入口,点击可查看个人详细时长面板及全服时长排行榜。 - -## 4. Data - -### 纯内存实时态(无锁轻量结构) - -维护 `Map`: -- `sessionStartMs`: 本次进服(或上次心跳结转时)的毫秒时间戳; -- `baseTodaySeconds`: 登录时(或上次结转后)库中当日基准秒数; -- `baseMonthSeconds`: 登录时(或上次结转后)库中当月基准秒数; -- `baseTotalSeconds`: 登录时(或上次结转后)库中历史累计基准秒数; -- `sessionTotalSeconds`: 本次连入会话全流程累计秒数。 - -### 持久化表 - -| 表名 | 关键列 | 索引 | -|------|--------|------| -| `sfmc_online_time` | `player_id` VARCHAR(64) PK,`player_name` VARCHAR(64),`today_seconds` INT,`month_seconds` INT,`total_seconds` INT,`last_date` VARCHAR(10),`last_month` VARCHAR(7),`updated_at` BIGINT | `idx_online_today (today_seconds DESC)`,`idx_online_total (total_seconds DESC)` | - -## 5. Config - -`configs/online_time.json`: - -```json -{ - "timezone": "Asia/Shanghai", - "flush_interval_ticks": 3600 -} -``` - -- `timezone`: 显式声明跨日/跨月重置的时区基准(默认 `Asia/Shanghai` 即 UTC+8),杜绝隐式依赖宿主物理时区; -- `flush_interval_ticks`: 增量心跳刷盘间隔,默认 3600 ticks(约 3 分钟)。防止服务器异常断电或强杀进程丢失过多数据。 - -## 6. Services - -### provides - -| name | input | output | 语义 | -|------|-------|--------|------| -| `onlinetime.byPlayer` | `{ playerId: string }` | 见下方详情 | 查询目标玩家在线时长快照(在线时微秒级实时叠加当前会话增量) | -| `onlinetime.top` | `{ metric?: "today"\|"month"\|"total", limit?: number }` | 见下方详情 | 查询全服在线时长排行榜(按指定维度降序排列) | - -#### `onlinetime.byPlayer` 契约 - -```ts -// input -{ - playerId: string; -} - -// output -{ - playerId: string; - playerName: string; - isOnline: boolean; - sessionSeconds: number; // 离线时为 0 - todaySeconds: number; // 库基准 + 内存实时增量 - monthSeconds: number; // 库基准 + 内存实时增量 - totalSeconds: number; // 库基准 + 内存实时增量 - lastActiveAt: number; // Unix ms -} -``` - -#### `onlinetime.top` 契约 - -```ts -// input -{ - metric?: "today" | "month" | "total"; // 缺省为 "total" - limit?: number; // 默认 10,最大 50 -} - -// output -Array<{ - rank: number; // 排名(1-indexed) - playerId: string; - playerName: string; - seconds: number; // 对应维度的累计秒数 - formatted: string; // 格式化展示串(例如 "28小时45分") - isOnline: boolean; -}>; -``` - -### requires - -无(零外部强依赖,作为 Wave A 基础提供者)。 - -## 7. Lifecycle notes & 计算机制 - -- `afterWorldLoad`: **true**(需在世界就绪后挂载进退服监听与心跳计时器); -- **核心算法机制(打点 + 心跳结转)**: - 1. **进服打点(Player Join / Spawn)**: - - 查询 `sfmc_online_time`;若新玩家则初始化插入记录; - - 依据配置时区检查日期与月份:若时区日期 $\neq$ `last_date`,当日秒数清零;若月份 $\neq$ `last_month`,当月秒数清零; - - 在内存记录 `sessionStartMs = Date.now()` 与各基准值。 - 2. **极速无锁实时查询(Realtime Calculation)**: - - 当玩家在线时,未结转增量 $\Delta t = \lfloor (\text{Date.now()} - \text{sessionStartMs}) / 1000 \rfloor$; - - 实时今日时长 $= \text{baseTodaySeconds} + \Delta t$,累计时长同理; - - **彻底废除旧版每 20 ticks(1秒)全服遍历的累加循环**,主线程零计算压力。 - 3. **跨午夜平滑切片(Midnight Slicing)**: - - 心跳或查询时,若检测到自 `sessionStartMs` 以来跨越了时区午夜 0 点: - - 将跨午夜之前的秒数结转入前一天的 `today_seconds` 并归档入库; - - 重置 `baseTodaySeconds = 0`,并将 `sessionStartMs` 调整为今日零点时间戳; - - 确保玩家跨夜在线时数据平滑过渡,无任何突变失真。 - 4. **周期心跳防崩盘(Heartbeat Flush,默认 3 分钟)**: - - 定时器触发时,遍历在线玩家内存态,将增量 $\Delta t$ 批量结转落库: - `today_seconds += Δt`, `month_seconds += Δt`, `total_seconds += Δt`, `updated_at = now`; - - 刷新 `sessionStartMs = Date.now()` 并更新基准,将数据意外丢失窗口严格压缩在 3 分钟以内。 - 5. **退服与停机(Player Leave & Teardown)**: - - 玩家离线或服务器 `cleanup` 停机时,强制执行终态增量结算刷盘,并释放内存会话对象。 - -## 8. Acceptance criteria - -- [ ] 彻底取消每秒(20 ticks)的高频轮询累加,转为进服打点 + 心跳结转模型 -- [ ] 玩家执行 `!onlinetime` 格式化返回实时的本次/今日/本月/累计四段时长 -- [ ] 控制台调用优雅阻断拦截 -- [ ] `onlinetime.byPlayer` 真实注册,在线时微秒级叠加实时增量 -- [ ] `onlinetime.top` 真实注册,支持按 today/month/total 排序并返回排名字典 -- [ ] 跨日(0 点)与跨月依据配置时区(默认 `Asia/Shanghai`)平滑自动重置 -- [ ] 数据库表名统一为 `sfmc_online_time`,建立适当降序索引 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 在线时长兑换游戏币 / 道具(由独立业务模块消费 `onlinetime.byPlayer` 服务实现) -- 阶梯签到奖励系统 - -## 10. Legacy reference(仅参考) - -- `packages/online-time/` diff --git a/docs/superpowers/specs/modules/peace-area-design.md b/docs/superpowers/specs/modules/peace-area-design.md deleted file mode 100644 index b41d9dbf..00000000 --- a/docs/superpowers/specs/modules/peace-area-design.md +++ /dev/null @@ -1,87 +0,0 @@ -# peace-area — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 作为挂接在 `area` 空间引擎上的声明式特性组件(`peace`),提供和平区域保护与怪物实体过滤。在声明了 `peace` 特性的空间区域内(如主城、新人生存区、交易集市),自动清理或拦截所有敌对生物族群(Monster Family),防止怪物刷出、游荡骚扰及袭击玩家,保障区域绝对和平。 - -**Non-goals:** -- 不自建独立的空间边界几何计算,依赖上游 `area` 空间插槽 -- 不篡改生物原生 AI 行为树或仇恨系统,仅负责区域内的实体拦截与安全销毁 -- 严禁向外部导出内部实现类 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `peace-area` | -| npm | `@sfmc-bds/module-peace-area` | -| manifest.id | `peace-area` | -| configKey | `peace_area` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `["area"]` | - -## 3. Player surface - -纯空间插槽驱动,无面向普通玩家的交互命令。 - -## 4. Data - -纯内存实体过滤,无数据库持久化表。 - -## 5. Config - -`configs/peace_area.json`: - -```json -{ - "target_families": ["monster"], - "exclude_entities": [ - "minecraft:iron_golem", - "minecraft:snow_golem" - ] -} -``` - -- `target_families`:目标清除的实体族群类型(默认 `monster` 敌对怪物); -- `exclude_entities`:豁免清理的友好/防卫型实体白名单(如铁傀儡、雪傀儡)。 - -## 6. Services - -### provides - -无。 - -### requires - -| name | 来自 | 用途 | -|------|------|------| -| `area.registerFeature` | [area-design.md](./area-design.md) | 向底层空间引擎注册 `peace` 特性生命周期处理器 | -| `area.byPoint` | 同上 | 实体生成时点查坐标是否处于和平区 | - -## 7. Lifecycle notes - -- `afterWorldLoad`: **true** -- `registerEvents`:调用 `area.registerFeature` 挂接 `peace` 特性生命周期处理器;订阅实体生成事件: - - **前置/后置生成拦截**:监听 `entitySpawn`,若生成实体属于 `target_families` 且不在白名单内,通过 `area.byPoint` 点查坐标;若处于和平区,立即调用 `entity.remove()` 销毁,阻止怪物露面; - - **`onTick(ctx, params)`**:由 `area` 引擎定期触发,扫描该和平区域内的全部存活实体,兜底清除游荡误入或穿透生成的怪物; - - 安全过滤:严格校验实体类型,坚决不误杀玩家宠物、坐骑、村民与友好 NPC。 - -## 8. Acceptance criteria - -- [ ] 成功调用 `area.registerFeature` 挂接 `peace` 特性 -- [ ] 和平区域内敌对生物生成即时被拦截清除 -- [ ] 外部游荡怪物进入和平区在下个周期内被自动销毁 -- [ ] 铁傀儡、友好生物与玩家宠物不受任何误伤 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 细分区域自定义过滤白名单/黑名单族群 - -## 10. Legacy reference(仅参考) - -- `packages/area/`(原 peace 生物过滤子能力抽离重构) diff --git a/docs/superpowers/specs/modules/qa-design.md b/docs/superpowers/specs/modules/qa-design.md deleted file mode 100644 index 94ed8c92..00000000 --- a/docs/superpowers/specs/modules/qa-design.md +++ /dev/null @@ -1,99 +0,0 @@ -# qa — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 定时向全服播发知识问答题目,挂接至 `chat` 模块的前置拦截器插槽捕获玩家快捷作答(`!答案`);支持加权题库抽选、答案正则/字面量判定,以及涵盖节操货币、游戏道具与指令执行的多样化奖惩分发。 - -**Non-goals:** -- 首版不提供图形化题库管理与在线编辑 GUI(题库完全由服务端 JSON 配置文件驱动) -- 不设计跨服务器的分布式竞答同步 -- **禁止私自裸监听原生 `world.beforeEvents.chatSend`**,出站题目广播统一调用 `chat.broadcast`,作答判定经由 `chat.registerInterceptor` 拦截插槽完成 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `qa` | -| npm | `@sfmc-bds/module-qa` | -| manifest.id | `qa` | -| configKey | `qa` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `["economy", "chat"]` | - -## 3. Player surface - -纯聊天管道交互,无独立注册命令。挂接至 `chat` 模块的前置拦截插槽,玩家输入前缀 `!` 或 `!` 后跟答案文本进行竞答;若当前轮次存在活跃题目,系统拦截该消息(返回消费标记)阻止进入常规公屏聊天,并在内部判定作答对错。 - -## 4. Data - -纯内存轮次状态机,无数据库持久化表。 - -## 5. Config - -`configs/qa.json` 必须采用对象结构(杜绝旧版顶层裸数组的非标格式): - -```json -{ - "questions": [ - { - "weight": 1, - "question": "Minecraft 的主世界共有几个主要生物群系?", - "answers": ["60", "六十"], - "msg_right": "恭喜回答正确!", - "msg_wrong": "回答错误,再接再厉!", - "explanation": "截至当前版本主世界拥有超过 60 种生物群系。", - "rewards": [{ "type": "money", "amount": 10 }], - "punishments": [] - } - ], - "qa_interval_min": 600, - "qa_interval_max": 720, - "qa_timeout": 60 -} -``` - -- `Bonus.type` 支持:`money`(节操货币)、`item`(物品给予)、`cmd`(控制台指令); -- 安全边界:若题库 `questions` 为空或解析失败,模块在 `init` 阶段输出警告并不启动竞答计时器,安全空转。 - -## 6. Services - -### provides - -无。 - -### requires - -| name | 来自 | 用途 | -|------|------|------| -| `economy.account.credit` | [economy-design.md](./economy-design.md) | 回答正确发放节操奖励 | -| `economy.account.debit` | 同上 | 回答错误执行节操扣款惩罚(扣至 0 为止,不产生负数) | -| `chat.broadcast` | [chat-design.md](./chat-design.md) | 全服广播竞答题目、获胜喜报与超时答案解析 | -| `chat.registerInterceptor` | 同上 | **进程内插槽接入**:注册作答前置拦截器,捕获并消费玩家竞答消息 | - -跨模块调用遵循两阶段幂等原则,传入由轮次与玩家 ID 构成的业务唯一 `idempotencyKey`。 - -## 7. Lifecycle notes - -- `afterWorldLoad`: **false** -- `registerEvents`:调用 `chat.registerInterceptor` 注册作答前置拦截插槽,严禁裸听原生聊天事件 -- 题库调度与防重:基于加权随机算法抽题,并维护容量为 5 的最近题目前置队列,避免连续抽到相同题目;出题时调用 `chat.broadcast`; -- 轮次生命周期:每轮题目广播后启动倒计时;每个玩家每轮仅限提交一次答案;有人答对即发放奖励并结束本轮;超时未答出时系统经由 `chat.broadcast` 播发答案与解析并平稳结束。 - -## 8. Acceptance criteria - -- [ ] 严格校验配置文件形状;空题库与坏配置安全降级 -- [ ] 接入 `chat` 拦截插槽与广播服务,答案判定、超时公布与奖惩结算全流程闭环 -- [ ] 节操奖惩正确调用 `economy.account.*` 跨模块服务并传递幂等键 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 多语言题库、连胜加成 - -## 10. Legacy reference(仅参考) - -- `packages/qa/` diff --git a/docs/superpowers/specs/modules/spawn-protect-design.md b/docs/superpowers/specs/modules/spawn-protect-design.md deleted file mode 100644 index e1bee0b4..00000000 --- a/docs/superpowers/specs/modules/spawn-protect-design.md +++ /dev/null @@ -1,62 +0,0 @@ -# spawn-protect — Design Spec - -> **Status:** approved -> **Spec authority:** this file. Legacy archive code is reference-only. - -## 1. Goal / Non-goals - -**Goal:** 当玩家初次进入世界或死亡重生(Spawn / Respawn)时,自动为其施加短时高阶伤害抗性增益(Resistance),有效防范因出生点刷怪围堵、高空坠落或网络加载延迟引发的落地秒杀现象。 - -**Non-goals:** -- 模块极简自治,不引入数据库表持久化 -- 不提供面向玩家的手动控制命令 - -## 2. Identity - -| 字段 | 值 | -|------|-----| -| install id | `spawn-protect` | -| npm | `@sfmc-bds/module-spawn-protect` | -| manifest.id | `spawn-protect` | -| configKey | `spawn_protect` | -| enabledByDefault | true | -| canDisable | true | -| manifest.requires | `[]` | - -## 3. Player surface - -纯事件响应机制,无玩家命令、无表单界面。 - -## 4. Data - -纯内存与原生实体状态,无数据库持久化。 - -## 5. Config - -无需强制配置,保留 `configKey: spawn_protect` 占位。内置保护常量定义: -- 药水效果标识:`minecraft:resistance` -- 保护时长:严格采用 `duration = 60 ticks`(约 3 秒,杜绝旧版秒/刻混用的裸数值); -- 效果等级:`amplifier = 5`(最高免伤等级)。 - -## 6. Services - -无。 - -## 7. Lifecycle notes - -- `afterWorldLoad`: **false**(可在启动期挂载 `playerSpawn` 事件监听) -- 触发策略:监听 `world.afterEvents.playerSpawn`(涵盖初次进服 `initialSpawn: true` 与死亡复活 `initialSpawn: false`);若玩家当前未带有同等或更高抗性效果,则施加保护;在 `cleanup` 阶段彻底注销事件回调。 - -## 8. Acceptance criteria - -- [ ] 进服与复活时正确施加抗性效果,时长与等级与 §5 一致 -- [ ] 已有高阶抗性时不发生非预期覆盖或缩减 -- [ ] typecheck / lint / test 通过 - -## 9. Deferred - -- 可配置时长/等级 - -## 10. Legacy reference(仅参考) - -- `packages/spawn-protect/` diff --git a/docs/superpowers/specs/ui-studio-design.md b/docs/superpowers/specs/ui-studio-design.md deleted file mode 100644 index ebae40af..00000000 --- a/docs/superpowers/specs/ui-studio-design.md +++ /dev/null @@ -1,298 +0,0 @@ -# SFMC UI Studio 与声明式 UI 设计方案 - -> 状态:基础契约已落地,Studio 与 Runtime 尚未实现 -> -> 文档格式:`formatVersion: 1` -> -> 目标读者:模块开发者、UI Studio 开发者、GUI Runtime 维护者 - -## 1. 结论 - -SFMC GUI 采用“可视化编辑器生成声明式 JSON,运行时解释执行”的路线。 - -模块作者不需要学习 TypeScript UI API,也不需要手写 JSON。日常入口是本地网页应用 **SFMC UI Studio**;JSON 是可版本控制、可校验、可迁移的中间产物。需要业务逻辑时,页面只能调用模块 manifest 已声明的 service,不能嵌入或执行任意脚本。 - -第一阶段不替换现有 GUI 模块。现有模块继续承担菜单入口、页面栈、错误恢复和 Minecraft 原生表单适配;新建一个声明式 Runtime 适配层,待页面能力覆盖后再逐模块迁移。 - -## 2. 设计目标 - -- 非程序员可以通过拖放、表单配置和数据选择器完成页面。 -- 编辑器、CLI、Runtime 使用同一份 TypeScript 契约和语义校验器。 -- 页面文件适合 Git:稳定排序、稳定节点 id、小范围差异、无二进制工程文件。 -- UI 只能组合已授权能力;权限校验最终仍由业务 service 负责。 -- 文档可以演进:带格式版本、提供迁移链、保存时保留当前编辑器不认识的字段。 -- 浏览器预览快速可用,同时明确它不是 Minecraft 客户端的像素级模拟器。 - -## 3. 非目标 - -- v1 不提供自由 CSS、任意 HTML 或脚本插件。 -- v1 不追求像素级布局;Minecraft 表单能力由 Runtime 决定。 -- v1 不允许页面直接访问数据库、配置文件或网络。 -- v1 不在浏览器预览中调用生产 BDS service。 -- v1 不一次性迁移所有现有 GUI 页面。 - -## 4. 系统边界 - -```text -模块作者 - │ 浏览器操作 - ▼ -SFMC UI Studio ──读写──> feature.ui.json + screens/*.ui.json - │ │ - │ 同一契约/校验器 │ Git 审查与版本控制 - ▼ ▼ -网页语义预览 CLI 校验/打包 - │ - ▼ - 声明式 UI Runtime - │ - 调用 manifest 已声明 service - │ - ▼ - 现有 GUI Host -``` - -职责划分: - -| 组件 | 负责 | 不负责 | -| --- | --- | --- | -| UI Studio | 页面树编辑、属性面板、绑定选择、语义预览、撤销重做、原子保存 | 执行业务代码 | -| 共享契约 | 文档类型、JSON Schema、结构与跨文件诊断 | Minecraft 呈现 | -| CLI | 项目发现、校验、迁移、打包、启动 Studio | 页面交互 | -| 声明式 Runtime | 加载、绑定求值、service 调用、效果执行、渲染适配 | 绕过 service 权限 | -| 业务模块 | 提供有权限校验的 service 和业务数据 | 控制编辑器内部实现 | -| 现有 GUI Host | 菜单、页面栈、错误恢复、原生表单显示 | 理解业务数据结构 | - -## 5. 工程文件 - -建议每个带 UI 的模块使用以下布局: - -```text -ui/ -├─ feature.ui.json -├─ screens/ -│ ├─ home.ui.json -│ └─ detail.ui.json -└─ .ui-studio/ - └─ preview.fixture.json -``` - -- `feature.ui.json`:模块入口、玩家/管理界面分组和页面文件清单。 -- `screens/*.ui.json`:独立页面;页面移动或拆分不会改写整个工程。 -- `.ui-studio/preview.fixture.json`:仅供本地预览的假数据,不进入 Runtime 包。 -- `$schema`:允许 VS Code 等普通编辑器提供补全,但 UI Studio 才是主要编辑方式。 -- 文件使用标准 JSON,不使用 JSONC。注释需求由 `name`、`notes` 和编辑器说明字段承担。 - -Studio 保存时采用固定字段顺序和两个空格缩进。数组顺序代表页面显示顺序;对象键按用户编辑顺序保持稳定,不做无意义的全文件排序。 - -## 6. 文档模型 - -### 6.1 Feature - -Feature 定义“页面属于哪个模块、从哪里进入、有哪些页面文件”。入口包含: - -- `surface`:`player` 或 `admin`; -- `group`:菜单分组; -- `title`、`description`、`icon`、`order`:入口展示信息; -- `permission`:入口可见性提示,不代替 service 的权限检查; -- `target`:目标页面 id。 - -页面引用同时保存 `id` 与 `file`。编译时两者必须与页面文件内部 id 一致,以便稳定重命名和精确诊断。 - -### 6.2 Screen - -页面由以下部分组成: - -| 字段 | 用途 | -| --- | --- | -| `presentation` | `auto`、`menu`、`form` 或 `reactive` 呈现建议 | -| `params` | 从页面跳转传入的只读路由参数 | -| `state` | 当前玩家、当前打开会话的输入状态 | -| `load` | 打开或刷新页面时调用的只读数据源 | -| `derived` | 由数据、参数和状态计算出的安全表达式 | -| `body` | 按顺序排列的组件树 | -| `actions` | 用户触发的命名 service 调用及完成效果 | - -所有组件必须带稳定 `id`。Studio 创建组件时生成可读 id,并在复制时生成新 id;拖动排序只改变数组位置。id 用于选择状态、撤销重做、诊断定位和 Git 差异,不是显示文本。 - -### 6.3 数据作用域 - -模板字符串使用 `{{root.path}}`: - -| 根 | 来源 | 示例 | -| --- | --- | --- | -| `player` | 当前玩家只读摘要 | `{{player.name}}` | -| `params` | 页面路由参数 | `{{params.landId}}` | -| `data` | `load` 的结果 | `{{data.land.name}}` | -| `state` | 表单输入状态 | `{{state.renewYears}}` | -| `derived` | 安全表达式结果 | `{{derived.renewCost}}` | -| `result` | 当前 action 的返回值 | `{{result.message}}` | -| 列表别名 | `each.as` 声明的局部变量 | `{{land.name}}` | - -模板仅做值绑定,不解释 JavaScript。结构判断和计算使用对象形式的表达式 AST,例如: - -当一个 JSON 字符串完全由单个绑定组成(如 `"{{state.renewYears}}"`)时,Runtime 保留原值类型,因此可以向 service 传递数字或布尔值;绑定与其他文字混合时,结果统一转换为展示字符串。 - -```json -{ - "op": "greaterThanOrEqual", - "args": [ - { "ref": "data.account.balance" }, - { "ref": "derived.renewCost" } - ] -} -``` - -Runtime 只实现白名单操作符,不使用 `eval`、`Function` 或动态模块加载。 - -### 6.4 组件集合 - -v1 组件刻意保持小而完整: - -| 类别 | 组件 | Minecraft 映射 | -| --- | --- | --- | -| 展示 | `header`、`text`、`info`、`image`、`divider`、`spacer` | 标题、正文、body、图标或降级文本 | -| 操作 | `button` | ActionForm 按钮或 DDUI 按钮 | -| 输入 | `textField`、`toggle`、`dropdown`、`slider` | ModalForm 对应控件 | -| 结构 | `when`、`each` | 求值后生成组件序列 | - -`presentation: auto` 由编译器依据组件推导;混合复杂页面优先使用 `reactive`。某个宿主不支持的展示能力必须有明确降级规则,而不是静默丢失。 - -### 6.5 动作与效果 - -按钮只有一个判别明确的 `trigger`,避免同一组件同时声明跳转和回调导致优先级不清: - -- `action`:执行页面 `actions` 中的命名动作,可通过 `input` 携带当前列表项等局部数据; -- `navigate` / `replace`:页面栈跳转; -- `back` / `refresh` / `close`:标准导航行为。 - -Action 只能调用一个已声明 service,可选择确认步骤,然后执行标准效果:消息、跳转、替换、返回、刷新、关闭或更新 state。业务返回值通过 `result` 作用域读取。 - -重要规则:入口 `permission` 只影响可见性。业务 service 必须根据调用玩家再次鉴权,不能信任页面传入的玩家 id、价格或权限标记。 - -## 7. UI Studio 交互设计 - -Studio 使用三栏加底部诊断区: - -```text -┌──────────────┬─────────────────────────────┬──────────────────┐ -│ 工程/页面树 │ 语义预览画布 │ 属性与数据绑定 │ -│ │ │ │ -│ Feature │ [标题] 我的领地 │ 组件:按钮 │ -│ ├ home │ ┌─────────────────────────┐ │ 文本:... │ -│ └ detail │ │ 主城领地 │ │ 动作:打开 detail │ -│ │ └─────────────────────────┘ │ 参数:landId │ -│ 组件库 │ │ │ -├──────────────┴─────────────────────────────┴──────────────────┤ -│ 问题:2 /body/3/trigger/to 未声明页面 ... │ -└───────────────────────────────────────────────────────────────┘ -``` - -关键工作流: - -1. `sfmc ui studio <模块目录>` 启动本地服务并打开浏览器。 -2. 用户从组件库拖入组件,画布显示近似 Minecraft 的语义预览。 -3. 属性面板根据 JSON Schema 生成基础字段,根据共享契约提供专用编辑器。 -4. 数据绑定不要求输入路径:用户从 `玩家 / 参数 / 数据源 / 状态 / 计算值` 树中选择。 -5. 配置 service 时仅列出 manifest 已声明能力;输入字段由后续 service schema 提供。 -6. 任何操作进入撤销栈;保存前显示文件级差异与诊断。 -7. 保存采用临时文件 + 原子替换;首次改写旧版本前创建可恢复备份。 - -属性面板不直接展示全部 JSON。高级用户可以打开“源码”视图,但源码与可视化编辑共享同一文档模型和撤销历史。 - -## 8. 预览策略 - -网页预览的目标是验证信息结构、顺序、条件和数据绑定,不承诺字体、间距与 Minecraft 完全一致。 - -- 默认使用 `.ui-studio/preview.fixture.json` 假数据。 -- 数据源可在“成功 / 空数据 / 失败 / 延迟”场景间切换。 -- Action 默认只展示将要调用的 service、input 与效果,不实际发出调用。 -- 后续可提供显式的“连接测试服”模式;必须由用户主动开启并显示醒目标识。 -- 最终视觉与交互验收仍在测试 BDS 和 Minecraft 客户端完成。 - -## 9. 校验与诊断 - -校验分三层,但对用户只显示一个问题列表: - -1. JSON 解析:文件是否为合法 JSON; -2. Schema:必填字段、字段类型、枚举和组件结构; -3. 语义编译:重复 id、页面引用、动作引用、数据作用域、路径安全和 service 清单。 - -每条问题包含稳定错误码、JSON Pointer 路径和中文消息。Studio 点击问题可定位文件与组件;CLI 使用相同消息输出。发布和部署前必须执行语义编译,Runtime 对漏网错误采用失败关闭,不猜测用户意图。 - -## 10. 保存、兼容与恢复 - -- Studio 在内存中保留原始 JSON 对象,修改已知字段时不删除未知字段。 -- Schema 对当前版本保持严格;打开未来版本时进入只读模式,不能用旧 Studio 覆盖。 -- 每次格式变更递增 `formatVersion`,并提供 `vN -> vN+1` 纯数据迁移函数。 -- 自动迁移先备份,再生成可审查差异;不自动删除旧文件。 -- 文件监听发现外部修改时暂停自动保存,让用户选择重新载入或比较合并。 -- 写入限定在启动时指定的模块 `ui/` 根目录,拒绝绝对路径与 `..` 越界。 - -## 11. 本地服务安全 - -- 默认仅监听 `127.0.0.1`,不暴露到局域网。 -- 启动时生成短期会话令牌,浏览器写操作必须携带令牌并通过 Origin 检查。 -- API 使用解析后的真实路径验证所有读写目标仍在工程根内。 -- 不把文件内容、service 数据、凭据或遥测上传到外部服务。 -- 预览 fixture 禁止自动复制真实玩家数据;检测常见密钥字段时给出提交警告。 - -## 12. 版本控制中的唯一可信源 - -当前基础文件位于 SDK: - -- `src/contracts/ui-document.ts`:平台无关 TypeScript 契约; -- `src/validation/ui-document.ts`:结构与跨文件语义校验/编译; -- `schemas/ui-feature.v1.schema.json`:Feature JSON Schema; -- `schemas/ui-screen.v1.schema.json`:页面 JSON Schema; -- `schemas/examples/`:可导入的完整工程与预览数据。 - -Studio、CLI 和 Runtime 不得复制一份自己的字段定义。若专用属性面板需要展示元数据,应从 Schema 扩展关键字或共享组件注册表派生。 - -## 13. 实施顺序 - -### 阶段 0:基础准备(本次范围) - -- [x] UI v1 平台无关类型; -- [x] Feature 与 Screen JSON Schema; -- [x] 结构校验、引用校验和工程编译入口; -- [x] 覆盖列表、详情、输入与 action 的领地示例; -- [x] Studio 产品、文件、安全与迁移设计; -- [x] 最小高信号契约测试。 - -### 阶段 1:Runtime 垂直切片 - -- 实现模板与表达式求值器; -- 实现 `menu`、`form`、标准效果和 service 适配; -- 用一个只读列表页和一个可提交表单页贯通 BDS; -- 将运行时接入现有 GUI Host,但保留旧 API。 - -### 阶段 2:Studio MVP - -- 增加 `sfmc ui studio` 本地命令和受限文件 API; -- 实现工程树、组件树、画布、属性面板和诊断区; -- 实现拖放、复制、撤销重做、数据绑定选择器和原子保存; -- 实现 fixture 场景预览与源码差异视图。 - -### 阶段 3:迁移与增强 - -- 选择低风险模块试迁移,保留可回退旧页面; -- 为 service 增加 input/output schema,提升绑定自动补全; -- 增加格式迁移器、页面模板和可复用片段; -- 评估连接测试服与真机预览,不作为 Studio MVP 前置条件。 - -## 14. 建议优先确认的产品选择 - -以下选择不阻塞基础实现,但建议在 Studio MVP 开始前确认: - -1. **入口命令**:建议统一为 `sfmc ui studio`,不要另建独立全局 CLI。 -2. **主要画布**:建议做 Minecraft 风格的语义预览,不做自由布局画布。 -3. **文件粒度**:建议一页一文件;大型工程的 Git 冲突和加载体验更可控。 -4. **高级模式**:建议保留 JSON 源码视图,但默认隐藏,且不能绕过校验保存。 -5. **首个试点**:建议选“领地详情/续期”这种同时包含列表、输入、确认和 service 的页面,能尽早暴露契约缺口。 - -## 15. Runtime 开发前仍需补齐的契约 - -目前 `service` 只有名称,Studio 无法自动知道 input/output 字段。进入阶段 1 前,建议为 manifest service 增加可选的输入输出 JSON Schema 或引用;这是高质量数据绑定面板的关键。未提供 schema 的旧 service 可以继续通过手动键值表接入,但 Studio 应提示“弱类型绑定”。 - -本方案暂不增加 SDK 版本变更记录。等 Runtime 或 Studio 首次真正消费这些公共导出、准备发布 SDK 时,再一起确定 API 命名并添加 changeset,避免为仍可能调整的预备契约产生无意义版本。 diff --git a/docs/zh/api/index.mdx b/docs/zh/api/index.mdx index 4cf7646b..89004de2 100644 --- a/docs/zh/api/index.mdx +++ b/docs/zh/api/index.mdx @@ -29,7 +29,7 @@ curl http://127.0.0.1:3001/api/health | 业务场景 | 官方标准做法 | 禁忌与原则 | | :--- | :--- | :--- | -| **编写 SAPI 游戏业务模块** | **必须统一使用 `@sfmc-bds/sdk`** 提供的抽象封装。
- 数据库:`@sfmc-bds/sdk/sapi/db`
- 私有配置:`@sfmc-bds/sdk/sapi/config`
- 跨模块调用:`@sfmc-bds/sdk/sapi/service` | **绝对禁止直接通过 SAPI fetch 直连 `127.0.0.1:3001`**。SDK 会自动处理请求签名、身份注入与网络异常重试。 | +| **编写 SAPI 游戏业务模块** | **统一使用 `@sfmc-bds/sdk`** 提供的抽象封装。
- 数据库:`@sfmc-bds/sdk/sapi/db`
- 私有配置:`@sfmc-bds/sdk/sapi/config`
- 跨模块调用:`@sfmc-bds/sdk/sapi/service` | SDK 会自动处理身份注入与网络异常重试,不建议在业务模块中手写 fetch 直连 `127.0.0.1:3001`。 | | **Node.js 侧扩展与外部脚本** | 可直接发起 HTTP REST 请求(参考本节各平台 HTTP 子路由)。 | 需在 Request Header 中显式携带 Bearer Token。 | | **查阅强类型签名** | 参阅自动生成的 [SDK 类型参考手册](../reference/index.md)。 | — | @@ -38,14 +38,14 @@ curl http://127.0.0.1:3001/api/health -1. [鉴权规范与错误码处理](./overview.md) — 了解微内核隔离、Bearer Token 注入与平台错误响应契约。 +1. [鉴权规范与错误码处理](./overview.md) — 了解身份鉴权、Bearer Token 注入与平台错误响应契约。 2. [SDK 核心导读](./sdk/index.md) — 学习 `db`、`config`、`runtime`、`service` 四大抽屉的用法。 3. [官方模块服务目录](./modules/index.md) — 查阅经济(economy)、领地(land)、区域(area)等现成可调用的 RPC 接口。 -1. [鉴权与网关规范](./overview.md) — 统一请求头格式、状态码映射。 +1. [鉴权与通信约定](./overview.md) — 统一请求头格式、状态码映射。 2. 核心 REST 端点详解: - [模块管控 API](./module-control.md) — 查询已安装 catalog、启停状态切换。 - [配置管理 API](./config.md) — 获取全局启动快照(`configs/all`)与命名空间持久化。 diff --git a/docs/zh/api/messages.md b/docs/zh/api/messages.md index 7bd2804e..3e74ea41 100644 --- a/docs/zh/api/messages.md +++ b/docs/zh/api/messages.md @@ -1,6 +1,6 @@ # 消息 -表 `sfmc_chat_messages`,用于游戏内频道与 QQ 桥。运维见 [使用指南 · QQ](../guide/qq-bridge.md)。 +表 `sfmc_chat_messages`,用于游戏内频道与 QQ 桥。运维见 [使用指南 · QQ](../guide/qq-bridge.mdx)。 ## GET /api/sfmc/messages diff --git a/docs/zh/api/module-control.md b/docs/zh/api/module-control.md index 8448b0ab..fa7f1b9f 100644 --- a/docs/zh/api/module-control.md +++ b/docs/zh/api/module-control.md @@ -4,7 +4,7 @@ 与 [模块服务目录](./modules/index.md) 不同:本页是**平台管模块**;服务目录是**模块对外业务 RPC**。 -日常启停优先 CLI:`sfmc mod enable|disable`(**本地写 lock**,db 在线时再 best-effort 热同步)。HTTP 下列路由由 **db-server** 提供,适合自动化与已起 db 的环境。运维说明见 [使用指南 · 模块](../guide/modules.md)。 +日常启停优先 CLI:`sfmc mod enable|disable`(**本地写 lock**,db 在线时再 best-effort 热同步)。HTTP 下列路由由 **db-server** 提供,适合自动化与已起 db 的环境。运维说明见 [使用指南 · 模块](../guide/modules.mdx)。 ## GET /api/sfmc/modules @@ -35,14 +35,14 @@ curl -X POST http://127.0.0.1:3001/api/sfmc/modules/feature-afk/enable curl -X POST http://127.0.0.1:3001/api/sfmc/modules/feature-afk/disable ``` -`canDisable: false` 的模块禁用会返回 `module_cannot_disable`。 +注:Manifest v2 中已废除 `canDisable` 字段,模块启停统一由 `module-lock.json` 管理;若服务端对特定保留模块限制禁用,将返回 `module_cannot_disable`。 ## 错误 | error | 含义 | | ------ | ------ | | `module_not_found` | key 不存在 | -| `module_cannot_disable` | 模块声明不可禁用 | +| `module_cannot_disable` | 该模块不可被禁用 | | `dependency_unmet` | 启用时依赖未满足 | ## 与游戏的关系 diff --git a/docs/zh/api/monitoring.md b/docs/zh/api/monitoring.md new file mode 100644 index 00000000..12f7debd --- /dev/null +++ b/docs/zh/api/monitoring.md @@ -0,0 +1,39 @@ +# 平台运行监控 + +运行监控由 SDK 行为包宿主在世界加载后自动启动,不依赖 `monitor` 或 `qq-link` 模块。桌面总览和其他模块共享同一份采样。 + +## 采集与口径 + +- TPS:每 tick 记录墙钟毫秒,使用最近 100 个采样点计算速率并限制在 0–20。冷启动或时钟回退时显示未知;它不是 MSPT。 +- 在线玩家:通过 SAPI `world.getAllPlayers()` 采集,同时向现有状态接口提供玩家与世界快照。 +- 实体数量:约每 30 秒查询三个维度中的可访问实体,查询失败返回 `null`,并保留采集时间。 +- 视距区块估算:累加玩家客户端视距对应的面积,不扣除玩家间重叠,不代表真实已加载区块数量。 +- 主机内存、进程内存和累计 CPU 秒数:由数据服务读取宿主系统。进程资源只认当前实例的 BDS PID;权限不足或无法确认归属时返回未知。累计 CPU 时间不能按百分比展示。 + +正常速度下,每 100 tick 上报一次指标。低 TPS 时墙钟上报间隔会变长;桌面不会补造中间点。 + +## 查询入口 + +SDK 的 `@sfmc-bds/sdk/sapi/runtime` 导出 `getRuntimeMetrics()` 和 `getRuntimeTpsStatus()`,允许模块直接读取平台采样。初始化前快照为 `undefined`,TPS 为 `null`。不要修改返回值来控制采集。 + +管理协议握手新增 `metrics` 能力。支持该能力时,`metrics.read` 返回 `current`、`history`、`fresh`、`updatedAt`、`host`、`processes` 和 `resourcesUpdatedAt`。旧平台缺少能力时,客户端展示升级提示。 + +数据服务提供以下入口: + +| 入口 | 用途 | +| ----------------------------- | -------------------------------------------------- | +| `POST /api/sfmc/metrics/live` | SDK 平台上报;沿用平台通道鉴权,校验值域与玩家计数 | +| `GET /api/sfmc/metrics` | 读取当前指标、最近一小时历史与宿主资源 | +| `GET /api/sfmc/status` | 兼容既有玩家与世界状态消费者 | + +指标保存在业务数据库同目录的 `runtime-metrics.sqlite` 中,避免加入模块的跨请求事务。默认保留 72 小时且最多 60000 条;单次历史读取最多 720 点。客户端断开、数据过期、游戏重启和采样中断时,当前值显示未知,图表按启动标识和时间间隔分段。 + +## monitor 弃用与兼容 + +`monitor` 已收编至平台。新行为包构建和 SDK 生命周期跳过旧模块;CLI 不再允许安装或启用,桌面与模块索引移除安装入口。无需手动删除旧包目录、`monitor.json`、启用记录或 `sfmc_monitor_*` 历史表。 + +平台保留 `/c:status`(高级管理员权限)、`tps.current`、`tps.status` 和 `monitor.metrics`。跨模块服务仍要求有效调用方及 `services.requires` 声明。既有 `requires: ["monitor"]` 视为已由平台满足,不能让任意模块声明 `_platform` 身份或抢占平台服务。 + +`monitor.metrics.totalLoadedChunks` 是为旧消费者保留的估算别名,新消费者应使用 `chunkEstimate`。采样不可用时兼容服务返回 `null` 或 `grade: "unknown"`,不伪报 20 TPS。旧历史留在原库,新历史不会自动覆盖或改写旧表。 + +更新 SDK 后必须重建并部署平台行为包,才能启用游戏内的采集;仅构建桌面客户端不会更新正在运行的 BDS。 diff --git a/docs/zh/api/overview.md b/docs/zh/api/overview.md index fda16706..2c26e679 100644 --- a/docs/zh/api/overview.md +++ b/docs/zh/api/overview.md @@ -1,14 +1,14 @@ # 鉴权机制与通信约定 -本文档系统阐释 SFMC 数据中枢(`db-server`)的请求鉴权模型、微内核沙盒隔离、标准响应结构与全局路由总览。 +本文档介绍 SFMC 数据服务(`db-server`)的鉴权模型、权限校验、标准响应结构与全局路由总览。 :::tip 模块业务首选 SDK -编写 SAPI 模块业务时,**请务必使用官方 SDK**(如 `db`、`config`、`service`),SDK 会透明完成模块身份与 Token 注入。本页主要用于底层调试、编写独立的外部 Node.js 工具或逆向排查通信问题。 +编写 SAPI 模块业务时,**请使用官方 SDK**(如 `db`、`config`、`service`),SDK 会自动处理模块身份与 Token 注入。本页主要用于底层调试、编写外部 Node.js 工具或排查通信问题。 ::: ## 1. 双重鉴权体系(Dual Auth Model) -为了在保证数据安全的同时不破坏本地通信性能,SFMC 采用**「平台管控鉴权 + 模块沙盒身份」**的双层鉴权模型: +为了在保证数据安全的同时兼顾本地通信效率,SFMC 采用**「平台管控鉴权 + 模块身份鉴权」**的双层鉴权模型: ```mermaid flowchart LR @@ -23,21 +23,21 @@ flowchart LR ### ① 平台级管控鉴权(Platform Bearer Token) - **配置源**:`configs/db_config.json` 的 `http_auth` 字段,或宿主环境变量 `HTTP_AUTH`。 -- **生效范围**:主要保护关键写操作(如修改系统配置、敏感运维命令)。 +- **生效范围**:保护关键写操作(如修改系统配置、敏感运维命令)。 - **传递方式**:在 HTTP 请求头中添加: ```http Authorization: Bearer ``` -- **缺省行为**:若 `http_auth` 为空字符串,则平台级鉴权处于开放状态(因仅限 Loopback 回环监听)。 +- **缺省行为**:若 `http_auth` 为空字符串,则平台级鉴权处于开放状态(仅限本地 Loopback 回环监听)。 -### ② 模块沙盒身份注入(Module Sandbox Identity) -- **工作机制**:每个模块在冷启动阶段通过 `ConfigManager.init()` 获取平台自动颁发的专属 `module_token`。 +### ② 模块身份注入(Module Identity) +- **工作机制**:每个模块在冷启动阶段通过 `ConfigManager.init()` 获取平台颁发的专属 `module_token`。 - **鉴权参数**:所有涉及数据库(`/api/sfmc/db/*`)、模块私有配置(`/api/sfmc/configs/*`)以及跨模块 RPC(`/api/sfmc/services*`)的请求,必须携带模块身份: ```http POST /api/sfmc/db/query?moduleId=feature-economy Authorization: Bearer ``` -- **权限核验**:`db-server` 会严格比对该模块在 `manifest.json` 中声明的 `permissions` 列表。一旦试图读写未声明的数据表或命名空间,请求将被立刻拒绝(`403 Forbidden`)。 +- **权限核验**:`db-server` 会比对该模块在 `manifest.json` 中声明的 `permissions` 列表。若试图读写未声明的数据表或命名空间,请求将被拒绝(`403 Forbidden`)。 ## 2. 标准响应与错误状态码规范 @@ -74,7 +74,7 @@ flowchart LR | `permission_denied` | `403` | 试图访问未在 `manifest.json` 中声明的表或跨模块服务。 | | `module_not_found` | `404` | 请求的模块 ID 在当前 `catalog.json` 中不存在。 | | `dependency_unmet` | `400` | 试图启用某个模块,但其依赖的前置模块尚未安装或处于禁用状态。 | -| `module_cannot_disable` | `400` | 试图禁用 `canDisable: false` 的模块。 | +| `module_cannot_disable` | `400` | 试图禁用受保护的内置模块。 | | `service_not_found` | `404` | 请求调用的跨模块服务名称未被任何已启用模块注册。 | ## 3. 全局核心路由索引地图 diff --git a/docs/zh/api/sdk/index.md b/docs/zh/api/sdk/index.md index 67a396c5..bcf60bc0 100644 --- a/docs/zh/api/sdk/index.md +++ b/docs/zh/api/sdk/index.md @@ -42,5 +42,5 @@ import { ModuleRegistry } from "@sfmc-bds/sdk/module-loader"; - 不 import 其它模块源码;跨模块用 service / client - 业务类型放在模块包内;`contracts` 仅平台 catalog/lock -对外服务:[模块服务目录](../modules/index.md)。HTTP 对照:[接口入门](../index.md)。 +对外服务:[模块服务目录](../modules/index.md)。HTTP 对照:[接口入门](../index.mdx)。 diff --git a/docs/zh/dev/architecture.md b/docs/zh/dev/architecture.md index ceff7972..a9d06980 100644 --- a/docs/zh/dev/architecture.md +++ b/docs/zh/dev/architecture.md @@ -1,72 +1,72 @@ # 平台架构设计 -本文档深入剖析 SFMC 的系统拓扑、通信协议、生命周期时序与状态真理源,阐述如何将**作者独立仓、npm 分发生态、伴生数据中枢与原生 Bedrock Dedicated Server**有机串联。 +本文介绍 SFMC 的系统架构分层、SAPI 模块生命周期、数据存储划分以及主仓工作区结构。 -## 1. 系统分层与拓扑全景 +## 1. 系统分层与拓扑 -SFMC 将复杂的开服系统划分为四个清晰解耦的层次: +SFMC 将系统划分为四个层次: ```mermaid flowchart TD - subgraph AuthorLayer ["1. 作者与分发面 (Distribution)"] - Repo["作者独立 Git 仓 (Template)"] -->|npm publish| NPM["npm 官方制品源"] - Repo -.->|登记元数据 PR| Index["sfmc-modules (轻量检索索引)"] + subgraph AuthorLayer ["1. 模块分发层"] + Repo["独立模块仓库"] -->|npm publish| NPM["npm Registry"] + Repo -.->|提交 PR 登记| Index["sfmc-modules (社区索引)"] end - subgraph OpsLayer ["2. 编排与组装面 (SFMC_ROOT)"] - CLI["sfmc CLI (编排 & REPL)"] + subgraph OpsLayer ["2. 服务端运行与构建层 (SFMC_ROOT)"] + CLI["sfmc CLI (管理与控制台)"] Index -->|mod search| CLI NPM -->|mod install| Packages["modules/packages/"] Packages --> Catalog["modules/catalog.json (镜像)"] - Lock["modules/module-lock.json (启停真理源)"] --> Assembler["esbuild 组装打包管线"] + Lock["modules/module-lock.json (启停状态)"] --> Assembler["esbuild 构建管线"] Catalog --> Assembler Assembler --> BP["packs/_build/sfmc-modules/"] end - subgraph NativeLayer ["3. 原生服务端 (Bedrock Engine)"] + subgraph NativeLayer ["3. BDS 原生服务"] BP --> Deploy["worlds//behavior_packs/sfmc-modules/"] - BDS["Bedrock Dedicated Server (C++)"] --> SAPI["Script API 虚拟机 (QuickJS)"] + BDS["Bedrock Dedicated Server"] --> SAPI["Script API 虚拟机"] Deploy --> SAPI end - subgraph ServiceLayer ["4. 伴生服务集群 (Companion Daemons)"] - DB["db-server (:3001)
SQLite 守护 & 鉴权中枢"] - QQ["qq-bridge (:3002)
QQ 开放平台 / LLBot 网关"] + subgraph ServiceLayer ["4. 辅助服务进程"] + DB["db-server (:3001)
SQLite 守护与鉴权服务"] + QQ["qq-bridge (:3002)
QQ 官方机器人 / LLBot 桥接"] QQ <== IPC / HTTP ==> DB end SAPI <== Loopback HTTP (127.0.0.1:3001) ==> DB ``` -## 2. SAPI 生命周期与启动序 +## 2. SAPI 生命周期与启动时序 -Minecraft BDS 的脚本引擎基于事件钩子驱动。SFMC 的 `installHostBootstrap()` 宿主脚本在启动时精确接管生命周期: +BDS 脚本基于原生事件钩子执行。SFMC 的 `installHostBootstrap()` 宿主脚本在启动时统一调度生命周期: ```mermaid sequenceDiagram autonumber - participant BDS as 原生 BDS 引擎 + participant BDS as BDS 引擎 participant Boot as 宿主引导层 (Host Bootstrap) participant CM as ConfigManager participant DB as db-server (:3001) participant Reg as ModuleRegistry participant Mod as 各业务模块 (Modules) - Note over BDS,Mod: 阶段一:系统冷启动 (startup) + Note over BDS,Mod: 阶段一:冷启动 (startup) BDS->>Boot: 触发 system.beforeEvents.startup Boot->>CM: ConfigManager.init() - CM->>DB: GET /api/sfmc/configs/all (获取全局配置与 Token 快照) + CM->>DB: GET /api/sfmc/configs/all (拉取全局配置与 Token) DB-->>CM: 返回 modules, settings, permissions, tokens Boot->>Reg: ModuleRegistry.bootAll() - loop 遍历所有处于 active 状态的模块 + loop 遍历所有启用的模块 Reg->>Mod: registerPermissions() (注册权限节点) - Reg->>Mod: 模块顶层 Command.register() 声明原生命令 + Reg->>Mod: 模块顶层 Command.register() 注册原生命令 Reg->>Mod: registerEvents() (注册系统/游戏事件) Reg->>Mod: init() (异步初始化:建表、状态准备) end - Boot->>BDS: announceLoaded() 打印模块装载就绪摘要 + Boot->>BDS: announceLoaded() 打印模块加载摘要 Note over BDS,Mod: 阶段二:世界加载就绪 (worldLoad) BDS->>Boot: 触发 world.afterEvents.worldLoad @@ -75,7 +75,7 @@ sequenceDiagram Reg->>Mod: init() (执行涉及实体、维度或坐标的操作) end - Note over BDS,Mod: 阶段三:服务停机销毁 (shutdown) + Note over BDS,Mod: 阶段三:服务停机 (shutdown) BDS->>Boot: 触发 system.beforeEvents.shutdown Boot->>Reg: ModuleRegistry.teardown() loop 逆序遍历模块 @@ -83,27 +83,27 @@ sequenceDiagram end ``` -### 单次配置快照设计(Zero-Polling) +### 单次配置快照与 Token 注入 -在启动阶段,`ConfigManager` 仅发起**一次** `GET /api/sfmc/configs/all` 请求,拉取当前启用的模块清单、系统设置、全局权限表以及各模块私有的通信 Token。 +启动阶段,`ConfigManager` 仅发起一次 `GET /api/sfmc/configs/all` 请求,拉取当前启用的模块列表、系统设置、全局权限表以及各模块专属通信 Token: -- **不进行运行时轮询**:快照直接固化在 SAPI 内存中,完全避免高频 HTTP 请求拖慢 Minecraft 游戏主刻(Tick)。 -- **Token 透明注入**:`ModuleRegistry.bootModule` 会自动将该模块的专用 Bearer Token 绑定至 `db` 客户端上下文。模块作者编写数据库操作时,无需手动管理任何鉴权密钥。 +- **运行时无需轮询**:快照缓存在 SAPI 内存中,避免频繁发起 HTTP 请求影响游戏主刻(Tick)。 +- **Token 自动注入**:`ModuleRegistry.bootModule` 会自动将该模块的专用 Bearer Token 绑定至 `db` 客户端上下文,业务模块无需手动配置鉴权密钥。 -## 3. 模块标准注册描述符 +## 3. 模块注册示例 -每个模块在 `sapi/src/index.ts` 中声明自身的生命周期钩子: +模块在 `sapi/src/index.ts` 中声明自身的生命周期钩子: ```ts title="sapi/src/index.ts" import { ModuleRegistry } from "@sfmc-bds/sdk/module-loader"; import { Command, Msg, Permission } from "@sfmc-bds/sdk/sapi/runtime"; -// 原生命令必须在 startup 前声明,公开名称为 /c:tp。 +// 原生命令需在顶层注册,公开名称为 /c:tp Command.register( "tp", "tp.use", (player) => { - if (player) Msg.info("正在发起传送...", player); + if (player) Msg.info("正在传送至主城...", player); }, "传送至主城", "feature-teleport" @@ -111,71 +111,68 @@ Command.register( ModuleRegistry.register({ id: "feature-teleport", - afterWorldLoad: false, // 若需在 init 中查询世界实体/维度,设为 true + afterWorldLoad: false, // 若需在 init 中查询世界实体或维度,设为 true lifecycle: { // 1. 声明模块所需权限节点 registerPermissions() { - Permission.register("tp.use", "使用传送功能", 0); - Permission.register("tp.admin", "管理员强行传送", 2); + Permission.register("tp.use", 0); + Permission.register("tp.admin", 2); }, // 2. 注册 Minecraft 原生事件监听 registerEvents() { - // 推荐在此处通过 world.afterEvents 进行订阅 + // 在此处通过 world.afterEvents 订阅事件 }, - // 4. 模块异步初始化 + // 3. 模块异步初始化 async init() { - // 声明 SQLite 数据表、初始化模块内部状态 + // 创建 SQLite 数据表、初始化模块内部状态 }, - // 5. 停机或卸载时的资源清理 + // 4. 停机或卸载时的资源释放 cleanup() { - // 清理定时器与内存缓存,防止内存泄漏 + // 清理定时器与缓存 }, }, }); ``` -## 4. 状态真理源分布矩阵 +## 4. 核心状态与数据划分 -SFMC 遵循严格的单一事实源(Single Source of Truth)原则: +| 数据项 | 存储位置 | 说明 | +| :--- | :--- | :--- | +| **模块契约** | `modules/packages//sapi/manifest.json` | 模块元数据,定义版本、权限需求与服务依赖。 | +| **模块索引** | `sfmc-modules/index.json` | 社区索引库,记录模块的 npm 包名与说明。 | +| **本地安装清单** | `/modules/catalog.json` | 本地已安装模块的元数据镜像。 | +| **模块启停状态** | `/modules/module-lock.json` | 记录模块是否启用,决定构建时是否打包该模块。 | +| **业务数据** | `/data/sfmc_data.db` | 由 `db-server` 托管的 SQLite 数据库文件。 | +| **平台全局配置** | `/configs/*.json` | 服务启动配置,支持环境变量覆盖。 | -| 领域数据 | 唯一权威真理源 | 同步与维护机制 | -| :----------------- | :----------------------------------------- | :--------------------------------------------------- | -| **模块契约** | `modules/packages//sapi/manifest.json` | 模块作者在模板中声明,定义版本、权限需求与外部依赖。 | -| **生态检索** | `sfmc-modules/index.json` | 官方轻量索引库,记录模块的 npm 包名与元数据。 | -| **本地安装清单** | `/modules/catalog.json` | 本地已安装模块的静态只读镜像。 | -| **模块启停状态** | `/modules/module-lock.json` | **唯一决定行为包是否打包该模块的布尔状态锁**。 | -| **持久化业务数据** | `/data/sfmc_data.db` | 由 `db-server` 托管的 SQLite 物理数据库。 | -| **平台级配置** | `/configs/*.json` | 首次启动时自动生成默认值,支持环境变量按需覆盖。 | +## 5. 主仓目录结构 -## 5. Monorepo 工作区架构(Platform Workspace) - -主仓源码采用 `pnpm` workspaces 架构统一管理: +平台主仓采用 pnpm workspaces 管理: ```text ScriptsForMinecraftServer/ ├── modules/ │ ├── sdk/ -│ │ ├── @sfmc-sdk/ # 统一 SDK 伞包(导出 runtime, db, config, module-loader) -│ │ └── @sfmc-eslint-plugin/ # 官方专属 ESLint 静态语法检查规则插件 -│ └── packages/ # SFMC_ROOT 下已安装业务模块的落地点(主仓默认留空) +│ │ ├── @sfmc-sdk/ # SDK 核心包(导出 runtime, db, config, module-loader) +│ │ └── @sfmc-eslint-plugin/ # ESLint 代码规范规则插件 +│ └── packages/ # SFMC_ROOT 下已安装业务模块的目录(主仓默认不包含) ├── packages/ -│ ├── db-server/ # 基于 node:sqlite 的轻量 HTTP 持久化中枢 -│ ├── qq-bridge/ # QQ 互通桥接服务(Official Bot & LLBot) +│ ├── db-server/ # 基于 node:sqlite 的本地 HTTP 持久化中枢 +│ ├── qq-bridge/ # QQ 互通桥接服务(官方机器人与 LLBot) │ ├── bds-tools/ # 官方 BDS 核心自动更新、备份与附加包管理 │ ├── devkit/ # 模块文件监听(Watch)与脚手架转译引擎 -│ ├── cli/ # @sfmc-bds/cli 终端交互与进程 Supervisor +│ ├── cli/ # @sfmc-bds/cli 终端交互与进程管理 │ ├── meta/ # @sfmc-bds/sfmc 全局命令行入口包装 -│ ├── create-module/ # pnpm dlx @sfmc-bds/create-module 脚手架生成器 -│ ├── sfmc-extension/ # VS Code / Cursor 官方开发专属扩展 -│ └── tools/ # 仓内专用测试、验证与文档生成工具集 -└── docs/ # 基于 Rspress 2 的多语言文档站源码 +│ ├── create-module/ # @sfmc-bds/create-module 脚手架生成器 +│ ├── sfmc-extension/ # VS Code / Cursor 官方开发扩展 +│ └── tools/ # 仓内专用测试、自检与构建脚本 +└── docs/ # 基于 Rspress 的多语言文档源码 ``` :::note 目录约定 - -- `packages/*`:平台级基础支撑包与核心工具,均可独立发版。 -- `modules/packages/*`:具体业务模块的运行镜像目录;平台核心代码与业务功能物理隔离。 - ::: +- `packages/*`:平台基础包与核心工具,均独立发布至 npm。 +- `modules/packages/*`:运行时的业务模块目录,与平台核心代码相互独立。 +::: diff --git a/docs/zh/dev/build-pipeline.md b/docs/zh/dev/build-pipeline.md index f301ab2d..991c8c8b 100644 --- a/docs/zh/dev/build-pipeline.md +++ b/docs/zh/dev/build-pipeline.md @@ -1,65 +1,62 @@ -# 行为包动态构建与装载管线 +# 行为包动态构建管线 -在传统的 Minecraft 基岩版开服流程中,行为包通常作为静态文件手动放入世界目录。但在 SFMC 架构中,**仓库内没有固定的行为包壳子**——所有启用的模块均由装配引擎在开服前**按需动态编译、打包与注入**。 +SFMC 采用动态打包机制:所有已启用的业务模块在开服或执行热重载时,由构建系统自动编译并注入到世界目录的 `sfmc-modules` 行为包(BP)与资源包(RP)中。本文介绍该构建管线的流程与关键规则。 -本篇文档向平台开发者与高级运维深入解析动态行为包(BP)与配套资源包(RP)的组装流水线。 +## 1. 构建流程与产物 -## 1. 动态流水线时序拓扑 - -整个构建流程由 CLI 与 `@sfmc-bds/bds-tools` 的 pack-manager 协同编排: +构建流程由 CLI 与 `@sfmc-bds/bds-tools` 的 pack-manager 协调执行: ```mermaid flowchart TD Lock["1. 读取 modules/module-lock.json
过滤 enabled: true 模块"] --> Collect["2. 收集各模块 sapi/src/index.ts 入口"] Collect --> Banner["3. 注入宿主引导代码
installHostBootstrap()"] Banner --> Esbuild["4. esbuild 转译与依赖外部化
(@minecraft/* external)"] - Esbuild --> BuildOut["5. 生成暂存中间制品
packs/_build/sfmc-modules/"] - BuildOut --> Deploy["6. 增量同步部署至
worlds//behavior_packs/sfmc-modules/"] - Deploy --> Catalog["7. 写入 sfmc-deploy-catalog.json
记录模块 SHA 指纹"] + Esbuild --> BuildOut["5. 生成暂存构建产物
packs/_build/sfmc-modules/"] + BuildOut --> Deploy["6. 同步部署至世界目录
worlds//behavior_packs/sfmc-modules/"] + Deploy --> Catalog["7. 写入 sfmc-deploy-catalog.json
记录模块指纹快照"] ``` -### 产物目录对照 +### 产物路径一览 -| 构建阶段 / 产物 | 落盘位置 | 说明 | +| 产物类型 | 输出路径 | 说明 | | :--- | :--- | :--- | -| **暂存构建制品** | `/packs/_build/sfmc-modules/` | 经过打包工具混淆编译后的暂存中间产物。 | -| **配套资源包 (RP)** | `/packs/_build/sfmc-modules-rp/` | 若启用的模块携带贴图、UI、音效,自动生成对应 RP。 | -| **目标世界 BP** | `/worlds//behavior_packs/sfmc-modules/` | 实际注入当前世界的行为包产物。 | -| **目标世界 RP** | `/worlds//resource_packs/sfmc-modules-rp/` | 实际注入当前世界的资源包产物。 | -| **部署镜像清单** | BP 根目录下 `sfmc-deploy-catalog.json` | 记录本次构建所包含的模块清单及源码哈希。 | +| **中间构建产物** | `/packs/_build/sfmc-modules/` | esbuild 编译后的暂存产物。 | +| **配套资源包 (RP)** | `/packs/_build/sfmc-modules-rp/` | 若启用的模块携带贴图、UI 或音效资源,自动生成配套 RP。 | +| **世界行为包 (BP)** | `/worlds//behavior_packs/sfmc-modules/` | 实际注入当前世界的行为包。 | +| **世界资源包 (RP)** | `/worlds//resource_packs/sfmc-modules-rp/` | 实际注入当前世界的资源包。 | +| **部署指纹清单** | BP 根目录下的 `sfmc-deploy-catalog.json` | 记录本次构建包含的模块列表及源码哈希。 | -## 2. 核心打包规则与边界保护 +## 2. 打包规则 -1. **白名单准入**:仅打包在 `modules/module-lock.json` 中明确标记为 `"enabled": true` 且在本地 `catalog.json` 存在的模块。未启用模块的源码绝不参与打包。 -2. **宿主引导注入**:以 `installHostBootstrap()` 脚本为总入口(Banner),在 `system.beforeEvents.startup` 挂载 `ConfigManager` 初始化,并统一触发各模块的 `ModuleRegistry.register` 注册。 -3. **原生依赖外部化(External)**:所有 `@minecraft/server`、`@minecraft/server-ui`、`@minecraft/server-net` 原生导入均声明为 external,直接由 BDS 内部宿主引擎提供,严禁打包进 bundle。 -4. **空包安全回退**:即使所有业务模块均被禁用,装配引擎仍会生成一个合法的空行为包骨架,确保服务端不会因资源包缺失而启动报错。 +1. **仅打包已启用模块**:只有在 `modules/module-lock.json` 中标记为 `"enabled": true` 的模块才会参与编译,禁用模块不会打包进产物。 +2. **宿主引导注入**:以 `installHostBootstrap()` 脚本为入口,在 `system.beforeEvents.startup` 事件中初始化 `ConfigManager` 并集中触发各模块的注册。 +3. **原生依赖外部化**:所有官方运行时包(`@minecraft/server`、`@minecraft/server-ui`、`@minecraft/server-net` 等)均声明为 external,由 BDS 引擎原生提供,不打入 bundle。 +4. **空包容底保护**:若所有业务模块均被禁用,构建系统仍会生成合法的空行为包骨架,避免 BDS 因缺少包报错。 -## 3. 手动控制与热重载命令 +## 3. 常用构建与重载命令 ```bash -# 仅执行编译与组装,不复制到世界目录(测试语法与体积) +# 仅执行编译与组装,不写入世界目录(用于检查语法与产物体积) sfmc> mod build -# 组装、部署至世界,并向运行中的 BDS 请求热重载 +# 编译、部署至世界,并向 BDS 发送热重载指令 sfmc> mod reload -# 仅组装并部署文件,不自动向游戏触发 reload 指令 +# 仅编译并写入世界,不向游戏发送 reload 指令 sfmc> mod reload --build-only ``` -:::tip 装载闸门自检 -日常开服时,`sfmc /start bds` 会在拉起进程前自动执行装载闸门(Load Gate)。它会自动比对目标世界内的 `sfmc-deploy-catalog.json` 与当前本地模块源码指纹: -- **若完全一致**:秒级跳过,直接开服。 -- **若检测到模块增删或源码变更**:自动执行全量构建部署,校验通过后才放行开服。 -详情查阅 [服务编排与管理 · 装载闸门](../guide/services.mdx#装载闸门)。 +:::tip 启动时自动检查 (Load Gate) +执行 `sfmc /start bds` 时,系统会自动比对世界目录内 `sfmc-deploy-catalog.json` 与当前本地模块的源码哈希: +- **无变更**:直接跳过构建开服。 +- **检测到模块增删或源码变更**:自动执行增量构建并部署后再启动服务端。 ::: -## 4. 变更生效矩阵速查 +## 4. 常见变更与生效方式 -| 改动类型 | 推荐生效动作 | 底层机理 | +| 改动内容 | 推荐生效操作 | 原理说明 | | :--- | :--- | :--- | -| **修改模块 SAPI 源码(`sapi/src/*.ts`)** | 执行 `sfmc mod reload` 或保存触发 Watch | 重新编译后通过 SAPI 热重载机制刷新运行时。 | -| **修改 `configs/*.json` 平台配置** | 必须**重启 BDS** 服务端 | 平台配置由 `ConfigManager` 在冷启动时固化为快照。 | -| **修改 `manifest.json` 契约** | 必须**重启 BDS** 服务端 | 权限变更需要 BDS 重新分配并由 db-server 重新颁发 Token。 | -| **修改 db-server 或 Node 伴生服务** | 仅重启对应 Node 进程(`restart db`) | SAPI 行为包无需重新构建部署。 | +| **修改模块 SAPI 源码(`sapi/src/*.ts`)** | 执行 `sfmc mod reload` 或保存触发 Watch | 重新编译后通过 SAPI 热重载刷新运行时。 | +| **修改全局配置(`configs/*.json`)** | **重启 BDS** | 配置由 `ConfigManager` 在启动时缓存。 | +| **修改模块清单(`manifest.json`)** | **重启 BDS** | 权限变更需要 BDS 重新分配并由 db-server 重新颁发 Token。 | +| **修改 db-server 或其他 Node 服务源码** | 仅重启对应服务(如 `restart db`) | SAPI 行为包不受影响,无需重新构建。 | diff --git a/docs/zh/dev/cheatsheet.mdx b/docs/zh/dev/cheatsheet.mdx index 8f820f96..b7434fbd 100644 --- a/docs/zh/dev/cheatsheet.mdx +++ b/docs/zh/dev/cheatsheet.mdx @@ -1,23 +1,23 @@ import { Tab, Tabs } from "@rspress/core/theme"; -# 核心 API 速查手册 (Cheatsheet) +# API 速查手册 (Cheatsheet) -本手册整理了开发 SFMC 业务模块时最常用的 SAPI 核心接口与代码片段,支持直接复制集成。 +本文整理了开发 SFMC 业务模块时常用的 SAPI 核心接口与代码片段。 -## 1. 模块生命周期与注册 (`ModuleRegistry`) +## 1. 模块入口与注册 (`ModuleRegistry`) -每个模块入口 `sapi/src/index.ts` 必须通过 `ModuleRegistry.register` 进行注册。 +每个模块在 `sapi/src/index.ts` 中通过 `ModuleRegistry.register` 注册: ```ts import { ModuleRegistry } from "@sfmc-bds/sdk/module-loader"; import { Command, Msg, Permission } from "@sfmc-bds/sdk/sapi/runtime"; -// 模块命令公开为 /c:tp;必须在 startup 前于模块顶层声明。 +// 注册玩家命令 /c:tp;需在模块顶层注册以参与启动期命令收集 Command.register( "tp", "tp.use", (player) => { - if (player) Msg.info(`传送目标: 主城`, player); + if (player) Msg.info("正在传送至主城...", player); }, "传送至主城", "feature-teleport" @@ -25,7 +25,7 @@ Command.register( ModuleRegistry.register({ id: "feature-teleport", - afterWorldLoad: false, // 若需在启动阶段访问实体/世界 API,设为 true + afterWorldLoad: false, // 是否需要等待世界完全加载后再执行 init lifecycle: { // 1. 注册权限节点 registerPermissions() { @@ -35,43 +35,44 @@ ModuleRegistry.register({ // 2. 注册 Minecraft 原生或自定义事件 registerEvents() { - // 推荐在此处订阅 world.afterEvents + // 在此处订阅 world.afterEvents }, - // 3. 异步初始化(读写配置、建立数据库连接等) + // 3. 异步初始化(读写配置、建表等) async init() { console.log("[feature-teleport] 初始化完成"); }, // 4. 卸载与资源释放 cleanup() { - // 清理定时器、临时缓存 + // 清理定时器与缓存 }, }, }); ``` -## 2. 消息与交互提示 (`Msg`) +## 2. 消息输出 (`Msg`) -严格禁止使用 `player.sendMessage()`,请统一使用规范化的 `Msg` 助手(支持格式码与统一色彩主题)。 +业务代码中请避免直接调用 `player.sendMessage()`,统一使用 SDK 提供的 `Msg` 助手以保持前缀与色彩风格统一: ```ts import { Msg } from "@sfmc-bds/sdk/sapi/runtime"; +import { world } from "@minecraft/server"; -// 常见通知类型 -Msg.info(player, "这是一条普通通知消息"); -Msg.success(player, "恭喜,交易完成!"); -Msg.warning(player, "注意:此区域禁止 PvP"); -Msg.error(player, "余额不足,无法购买!"); -Msg.tips(player, "小贴士:输入 /c:menu 可打开快捷菜单"); +// 发送给指定玩家(参数顺序:消息文本在前,玩家对象在后) +Msg.info("这是一条常规提示", player); +Msg.success("操作成功完成", player); +Msg.warning("注意:此区域禁止 PvP", player); +Msg.error("余额不足,操作失败", player); +Msg.tips("输入 /c:menu 可打开快捷菜单", player); -// 全服广播 -Msg.broadcast("全服公告:服务器将在 10 分钟后例行维护"); +// 全服广播原生消息 +world.sendMessage("§f[*] 全服公告:服务器将在 10 分钟后例行维护"); ``` -## 3. 表单正文规范 (`ListFormInfo`) +## 3. 表单排版 (`ListFormInfo`) -构建 `ActionFormData` 或 `MessageFormData` 时,正文排版请遵循平台统一视觉风格: +使用 `ActionFormData` 构建菜单面板时,可通过 `ListFormInfo` 格式化正文行: ```ts import { ActionFormData } from "@minecraft/server-ui"; @@ -79,20 +80,24 @@ import { ListFormInfo } from "@sfmc-bds/sdk/sapi/runtime"; const form = new ActionFormData() .title("领地管理面板") - .body(ListFormInfo(["当前领地:[主城保护区]", "领地主人:ServerAdmin", "当前权限:允许移动 / 禁止破坏"])) + .body(ListFormInfo([ + "当前领地:[主城保护区]", + "领地主人:ServerAdmin", + "当前权限:允许移动 / 禁止破坏", + ])) .button("购买领地") .button("权限设置") - .button("§c返回上一页"); // 仅返回按钮允许带颜色代码 + .button("§c返回上一页"); // 仅返回/关闭按钮建议带红字 form.show(player).then((response) => { if (response.canceled) return; - // 处理点击回调 + // 处理点击选项 }); ``` -## 4. 数据库 CRUD 与事务 (`@sfmc-bds/sdk/sapi/db`) +## 4. 数据库读写与事务 (`@sfmc-bds/sdk/sapi/db`) -使用 `@sfmc-bds/sdk/sapi/db` 进行隔离安全的 SQLite 数据持久化。 +使用 `@sfmc-bds/sdk/sapi/db` 进行 SQLite 数据操作: @@ -100,7 +105,7 @@ form.show(player).then((response) => { ```ts import { db, sql } from "@sfmc-bds/sdk/sapi/db"; -// 1. 初始化创建表(若不存在) +// 1. 初始化建表 await db.execute(sql` CREATE TABLE IF NOT EXISTS player_stats ( player_id TEXT PRIMARY KEY, @@ -109,7 +114,7 @@ await db.execute(sql` ) `); -// 2. 插入或更新 +// 2. 插入或更新记录 await db.execute(sql` INSERT INTO player_stats (player_id, points, last_login) VALUES (${player.id}, 100, ${Date.now()}) @@ -118,7 +123,7 @@ await db.execute(sql` last_login = excluded.last_login `); -// 3. 结果查询 +// 3. 查询单条数据 const result = await db.queryOne<{ points: number }>(sql` SELECT points FROM player_stats WHERE player_id = ${player.id} `); @@ -126,12 +131,12 @@ console.log(`玩家当前点数: ${result?.points ?? 0}`); ``` - + ```ts import { db, sql } from "@sfmc-bds/sdk/sapi/db"; -// 在事务中执行多步转账/扣款操作,保证 ACID +// 在事务中执行原子扣款与加款 await db.transaction(async (tx) => { // 1. 扣除发起者点数 const sender = await tx.queryOne<{ points: number }>(sql` @@ -151,7 +156,7 @@ await db.transaction(async (tx) => { UPDATE player_stats SET points = points + ${amount} WHERE player_id = ${targetId} `); - // 3. 记录交易流水 + // 3. 写入交易日志 await tx.execute(sql` INSERT INTO transfer_logs (sender, target, amount, created_at) VALUES (${senderId}, ${targetId}, ${amount}, ${Date.now()}) @@ -162,30 +167,29 @@ await db.transaction(async (tx) => { -## 5. 模块私有配置读写 (`@sfmc-bds/sdk/sapi/config`) +## 5. 模块配置读写 (`@sfmc-bds/sdk/sapi/config`) -模块在 `configs/.json` 拥有独立隔离的命名空间: -默认值写在模块包的 `configs-default/.json`,安装/更新时自动只补缺、不覆盖用户值。 +每个模块在 `/configs/.json` 拥有独立配置文件。模块可在 `configs-default/.json` 声明默认值,平台在安装与更新时会自动补齐缺失项且不覆盖已有值: ```ts import { config } from "@sfmc-bds/sdk/sapi/config"; -// 读取配置(带默认 fallback) +// 读取配置(第二个参数为默认回退值) const spawnPoint = await config.get("spawn_point", { x: 0, y: 64, z: 0 }); const enableWelcome = await config.get("enable_welcome", true); -// 运行时修改配置(即时持久化) +// 运行时修改配置并保存 await config.set("spawn_point", { x: 100, y: 70, z: 200 }); ``` -## 6. 跨模块服务依赖与 RPC (`@sfmc-bds/sdk/sapi/service`) +## 6. 跨模块服务调用 (`@sfmc-bds/sdk/sapi/service`) -当模块 A 需要调用模块 B 提供的服务时: +调用其他模块在 `manifest.json` 中声明的 RPC 服务: ```ts import { service } from "@sfmc-bds/sdk/sapi/service"; -// 调用由 feature-economy 提供的 balance 查询服务 +// 调用 feature-economy 提供的 balance 查询服务 const res = await service.call<{ balance: number }>("economy.balance", { playerId: player.id, }); diff --git a/docs/zh/dev/conventions.md b/docs/zh/dev/conventions.md index 3e326a72..4398adbf 100644 --- a/docs/zh/dev/conventions.md +++ b/docs/zh/dev/conventions.md @@ -1,96 +1,92 @@ # 代码与工程约定 -为了保障 SFMC 生态的稳定性、代码优雅性与长久可维护性,所有平台包与业务模块均需严格遵守以下工程约定。 +本文汇总 SFMC 平台包与业务模块的代码规范与工程约定。 -## 1. 交互与消息规范(Interaction & Messaging) +## 1. 交互与消息规范 ### 统一使用 `Msg` 助手 -- **绝对禁止在业务代码中直接调用 `player.sendMessage()`**(ESLint 规则:`@sfmc-bds/no-player-send-message`)。 -- 必须统一通过 `@sfmc-bds/sdk/sapi/runtime` 中的 `Msg` 助手输出提示。 -- 遵循色彩与语义对齐: - - `Msg.info`:常规中立信息(默认浅灰/亮白) - - `Msg.success`:操作成功、交易达成(绿色系) - - `Msg.warning`:权限警示、边界告警(黄色系) - - `Msg.error`:严重故障、参数校验失败(红色系) - - `Msg.tips`:玩法建议、命令说明(浅青/浅紫系) - - `Msg.broadcast`:全服广播 +- **业务模块中避免直接调用 `player.sendMessage()`**(对应 ESLint 规则:`@sfmc-bds/no-player-send-message`)。 +- 统一通过 `@sfmc-bds/sdk/sapi/runtime` 中的 `Msg` 助手向玩家发送提示,保持前缀格式与色彩一致: + - `Msg.info(msg, player)`:常规提示(前缀 `§f[*]`) + - `Msg.success(msg, player)`:操作成功(前缀 `§a[√]`) + - `Msg.warning(msg, player)`:告警与提示(前缀 `§e[!]`) + - `Msg.error(msg, player)`:错误提示(前缀 `§c[x]`) + - `Msg.tips(msg, player)`:玩法帮助与小贴士(前缀 `§7[!]`) +- 若需向全服广播消息,使用 `@minecraft/server` 的 `world.sendMessage()`。 -### 表单 UI 正文规范(`ListFormInfo`) +### 表单 UI 排版规范(`ListFormInfo`) -使用 `ActionFormData` 或 `ModalFormData` 展示复杂信息时,正文必须通过 `ListFormInfo(string[])` 助手格式化: +使用 `ActionFormData` 或 `ModalFormData` 展示多行详情时,推荐使用 `ListFormInfo(string[])` 助手格式化正文行: -- 第一行以 `[*]` 标头引出主体。 -- 后续每行进行规范缩进与对齐,杜绝杂乱无章的空行。 -- 按钮文字保持干净简洁,**除“§c返回/关闭”外,常规功能按钮禁止包含原版颜色格式码**。 +- 首行使用 `[*]` 标头引出主题。 +- 列表项保持统一缩进,避免多余空行。 +- 功能按钮文本建议简洁明了,仅返回/取消类按钮可使用颜色前缀(如 `§c返回`)。 -## 2. 命令与权限梯度(Commands & Permissions) +## 2. 命令与权限设计 -### 原生自定义命令统一接入 +### 玩家命令规范 -- 所有玩家命令统一使用 `/c:<命令>`,模块名不参与公开命名,例如 `/c:pay`、`/c:afk`。 -- 高频入口可以声明短别名;同一功能域的低频动作优先使用原生枚举子命令,例如 `/c:afk exempt`。 -- 所有业务命令通过模块顶层的 `Command.register` 声明,以便宿主在 `system.beforeEvents.startup` 的 early-execution 阶段提交原生注册;不要在生命周期钩子内延迟声明。 -- 系统会自动包裹 `moduleGuard` 保护门禁;一旦模块在 `module-lock.json` 中被停用,其关联命令会自动熔断拦截并向玩家返回友好提示,无需模块内部硬编码判断。 +- 所有玩家命令统一使用 `/c:<命令>` 形式注册,命令名称建议保持简短易记(如 `/c:pay`、`/c:afk`)。 +- 同一功能模块的子操作优先使用原生枚举子命令(如 `/c:afk exempt`),避免占用过多顶层命令。 +- 所有业务命令应在模块顶层通过 `Command.register` 注册,以便在启动期完成原生命令挂载。 +- 平台会自动为命令附加 `moduleGuard` 保护:当模块在 `module-lock.json` 中被禁用时,其关联命令会自动拦截并提示玩家,无需在模块内手动编写启停判断。 -### 四级权限阶梯定义 +### 权限等级定义 -权限节点必须在 `ModuleRegistry.register` 的 `registerPermissions()` 阶段集中声明,并映射至四级标准权限数: +权限节点在 `ModuleRegistry.register` 的 `registerPermissions()` 阶段集中声明,遵循 4 级权限分级: -| 权限等级 | 角色代号 | 典型场景 | -| :------: | :---------------------------- | :---------------------------------------------------------------- | -| **`0`** | **Any**(游客) | 基础交互命令(如 `/c:ping`、`/c:help`、`/c:online`、`/c:menu`)。 | -| **`1`** | **Member**(成员) | 正常玩家功能(如 `/c:home`、`/c:afk`、`/c:pay`)。 | -| **`2`** | **Admin / OP**(管理员) | 巡查与日常管理命令(如 `/c:admin_kick`、`/c:admin_mute`)。 | -| **`3`** | **Root / SuperAdmin**(超管) | 底层运维命令(如 `/c:reload`、权限分配)。 | +| 权限等级 | 级别名称 | 典型场景 | +| :---: | :--- | :--- | +| **`0`** | **Any**(所有人) | 基础查询与通用交互命令(如 `/c:ping`、`/c:help`、`/c:menu`)。 | +| **`1`** | **Member**(玩家) | 普通玩家日常功能(如 `/c:home`、`/c:afk`、`/c:pay`)。 | +| **`2`** | **Admin / OP**(管理员) | 管理与巡查命令(如 `/c:admin_kick`、`/c:admin_mute`)。 | +| **`3`** | **Root / SuperAdmin**(控制台/服主) | 底层运维命令(如 `/c:reload`、权限分发)。 | -## 3. 配置分层与防腐(Configuration Layers) +## 3. 配置管理规范 -1. **平台级配置(`configs/*.json`)**: - - 包含 `db_config.json`、`qq_config.json`、`bds_updater.json` 等。 - - SAPI 端的 `ConfigManager` 在冷启动阶段一次性缓存 `modules` / `settings` / `permissions`,运行时不进行轮询。 - - 变更平台级配置需**重启 BDS** 才能生效。 -2. **模块私有配置(`configs/.json`)**: - - 模块包在 `configs-default/.json` 声明全部默认字段;安装器负责创建并在升级时只补缺、不覆盖用户值。 - - 每个模块拥有独立的配置命名空间,通过 `@sfmc-bds/sdk/sapi/config` 提供的 `config.get` / `config.set` 进行透明读写。 - - `config.set` 会即时落盘;模块不得绕过 SDK 直接探测或读写文件系统。 - - 不得仅为播种默认值而申请 `config:write` 权限或在启动时调用 `config.set`。 +1. **平台全局配置(`/configs/*.json`)**: + - 包括 `db_config.json`、`qq_config.json`、`bds_updater.json` 等。 + - SAPI 端 `ConfigManager` 在冷启动阶段一次性加载快照,修改后需**重启 BDS** 生效。 +2. **模块专属配置(`/configs/.json`)**: + - 模块在作者仓 `configs-default/.json` 中声明默认配置。安装器首次安装时创建,升级时仅补充缺失字段,不会覆盖服主已有值。 + - 业务模块通过 `@sfmc-bds/sdk/sapi/config` 提供的 `config.get` / `config.set` 进行读写。 + - 不要在启动阶段仅为写入默认值而调用 `config.set`。 -## 4. 模块边界与架构防腐(Module Boundaries) +## 4. 模块依赖与隔离边界 -- **极简依赖**:业务模块仅允许依赖 `@sfmc-bds/sdk` 与官方 `@minecraft/*` 运行时包,严禁将未打包的外部大体积 Node 模块混入 SAPI 环境。 -- **跨模块调用标准**: - - 严禁通过相对路径直接 `import` 其它模块的内部源文件(ESLint 规则:`no-cross-module-source-import`)。 - - 严禁读取或修改其它模块声明的私有 SQLite 表。 - - 如需调用其它模块能力,必须在 `manifest.json` 中声明 `requires`,并统一走 `service.call` 或分布式事务 `tx.call`。 +- **依赖约束**:业务模块仅允许依赖 `@sfmc-bds/sdk` 与官方 `@minecraft/*` 运行时包,避免引入体积过大或带有 Node 原生依赖的第三方 npm 包。 +- **跨模块调用规则**: + - 禁止通过相对路径直接 import 其他模块内部源码(ESLint 规则:`no-cross-module-source-import`)。 + - 禁止直接查询或修改其他模块创建的专属 SQLite 表。 + - 跨模块通信需在 `manifest.json` 中声明 `requires`,通过 `service.call` 或事务内的 `tx.call` 完成。 -## 5. 数据库与 SQL 安全规范(Database & SQL) +## 5. 数据库与 SQL 安全规范 -- **参数化查询防注入**:所有 SQL 查询必须使用 SDK 导出的 `sql` 模板标签(例如 `sql`SELECT * FROM users WHERE id = ${userId}``),底层自动转换为预编译参数绑定,严禁手动通过字符串拼接拼凑 SQL。 -- **动态列名转义**:若涉及动态标识符(表名/列名),必须使用 `sql().append(raw(...))` 显式包裹,严禁将外部不可信输入作为裸 SQL 标识符执行。 -- **短事务原则**:事务(`db.transaction`)内仅执行必要的数据库读写与原子操作,禁止在事务临界区内发起耗时巨大的外部网络 I/O。 +- **使用参数化查询**:所有 SQL 查询必须使用 SDK 导出的 `sql` 模板标签(如 `sql`SELECT * FROM users WHERE id = ${userId}``),参数会自动绑定,禁止手动拼接字符串 SQL。 +- **动态标识符转义**:若表名或列名由变量决定,需使用 `raw(...)` 包裹,避免外部不可信输入被作为 SQL 语法执行。 +- **事务范围最小化**:事务(`db.transaction`)内仅执行必要的数据库原子读写,避免在事务内部包含耗时过长的网络请求或异步等待。 -## 6. 代码工程纪律(Code Quality & Formatting) +## 6. 代码格式与工程规范 -### 统一格式化标准(Prettier) +### 统一代码格式(Prettier) -- 双引号(`"`)、结尾逗号使用 ES5 规则(`trailingComma: "es5"`)。 +- 双引号(`"`),结尾逗号使用 ES5 规则(`trailingComma: "es5"`)。 - 单行最大字符数:`printWidth: 120`,缩进:`tabWidth: 2`。 -- Windows 仓库对齐换行符:`endOfLine: "crlf"`。 +- Windows 仓库统一样式:`endOfLine: "crlf"`。 -### 依赖规范(Syncpack) +### 依赖一致性(Syncpack) -在平台 Monorepo 根目录下,依赖必须保持严格一致: +主仓 Monorepo 根目录下依赖保持统一: ```bash pnpm run syncpack:fix pnpm exec syncpack format --check ``` -- 本地 `@sfmc-bds/*` 互引必须对齐真实版本号并使用 `^`,**严禁使用 `workspace:*`**,保障发版到 npm 后的独立可用性。 -- SDK 中对 `@minecraft/*` 的 Peer 依赖保持宽松兼容范围(`^1.x.x`)。 +- 本地 `@sfmc-bds/*` 互引使用具体的版本范围(`^`),**禁止使用 `workspace:*`**,以保证单独发布至 npm 后依赖有效。 +- 对 `@minecraft/*` 的 Peer 依赖保持宽松兼容范围(`^1.x.x`)。 -### 注释与编码 +### 注释规范 -- 代码注释统一采用**简体中文 UTF-8**,言简意赅,阐明核心设计意图而非复述语法。 -- 遵循经典架构设计原则:**DRY**(不重复)、**OCP**(开闭原则)、**DIP**(依赖倒置)、**迪米特法则**(最少知识原则)。 +- 代码注释统一使用简体中文,注重说明设计原因与边界条件,避免重复描述语法。 diff --git a/docs/zh/dev/index.mdx b/docs/zh/dev/index.mdx index 2651a21d..dc246fb4 100644 --- a/docs/zh/dev/index.mdx +++ b/docs/zh/dev/index.mdx @@ -2,91 +2,89 @@ import { Tab, Tabs } from "@rspress/core/theme"; # 开发指南 · 导读 -SFMC 提供了完备的现代化 TypeScript 开发套件。无论是希望为社区生态编写功能模块,还是参与平台核心与 SDK 建设,均可在此找到清晰规范的演进路径。 +本文档面向 SFMC 业务模块开发者与平台核心贡献者,介绍项目架构划分、开发环境要求与常见开发流程。 ```mermaid flowchart LR - A[开发导读] --> B[编写业务模块] - B --> C[类型检查 / 扩展联调 / 发布] - A --> D[平台架构设计] - D --> E[SDK / 核心组件开发] + A[开发导读] --> B[开发业务模块] + B --> C[本地联调 / 测试 / 发布] + A --> D[平台核心开发] + D --> E[SDK / db-server / CLI] ``` -## 1. 三层工程边界与真理源 +## 1. 仓库划分与工程边界 -在动手编码之前,理解 SFMC 的三层职责边界至关重要: +在开始开发前,需要先明确 SFMC 各仓库与目录的职责边界: -| 角色层次 | 核心职责与真理源 | 严禁与避免的误区 | +| 仓库 / 目录 | 职责定位 | 说明 | | :--- | :--- | :--- | -| **作者独立仓(Template)** | **业务逻辑的唯一编码面**。每个模块均为独立的 Git 仓库,拥有自己的 `package.json`、构建与测试流程。 | 严禁直接在主仓 `packages/` 目录下添加具体业务逻辑。 | -| **npm 制品源** | **模块发布的唯一法定渠道**。版本通过 npm Registry 进行语义化分发(`@sfmc-bds/module-` 或社区 scope)。 | 严禁依赖私有 Git 子模块或文件拷贝作为发布分发面。 | -| **模块索引(sfmc-modules)** | **社区检索的轻量索引**。仅包含 `index.json` 目录,供 CLI 搜索、版本匹配与展示。 | 该仓库并非 Monorepo 工作区,不承载任何实际模块代码。 | -| **主仓工作区(modules/packages/)** | **服务器运行态的安装目标**。可通过 `--link` 软链接挂接作者本地仓进行热联调。 | 并非日常业务开发的代码仓库。 | +| **独立模块仓库** | 编写业务模块代码的地方。每个模块都是独立的 Git 仓库,拥有自己的 `package.json`、构建与测试。 | 不要在平台主仓的 `packages/` 下直接写业务模块。 | +| **npm Registry** | 模块分发渠道。模块通过 npm 发布(如 `@alice/sfmc-module-teleport` 或 `@sfmc-bds/module-`)。 | 生产环境通过 CLI 从 npm 安装模块,避免直接复制文件或引用 Git 子模块。 | +| **模块索引(sfmc-modules)** | 社区模块目录。仅维护一个 `index.json`,供 `sfmc mod search` 检索和展示。 | 该仓库仅做元数据索引,不存储模块源代码。 | +| **平台运行目录(modules/packages/)** | 服务器运行时的模块安装目录。 | 本地开发时可通过 `--link` 软链接挂接到作者本地仓库进行联调。 | -## 2. 推荐开发环境配置 +## 2. 开发环境准备 -| 类别 | 推荐选项 | 说明 | +| 类别 | 推荐配置 | 说明 | | :--- | :--- | :--- | -| **操作系统** | Windows 10 / 11 或 Linux (Ubuntu 22.04+) | 建议开发机配置好本地 Node.js 与 Git 环境。 | -| **Node.js** | **`≥ 22.13.0`** (LTS) | 必须满足该版本以获得 Node 原生内置的 `node:sqlite` 支持。 | -| **包管理** | **`pnpm`** | 主仓采用 pnpm workspaces;本地开发包严禁使用 `workspace:*` 外部依赖。 | -| **编辑器** | [Cursor](https://cursor.com/) 或 [VS Code](https://code.visualstudio.com/) | 搭配专属扩展体验最佳。 | +| **操作系统** | Windows 10 / 11 或 Linux (Ubuntu 22.04+) | 建议配置好 Node.js 与 Git。 | +| **Node.js** | **`≥ 22.13.0`** (LTS) | 必须满足该版本以支持 Node 内置的 `node:sqlite`。 | +| **包管理器** | **`pnpm`** | 主仓采用 pnpm workspaces;独立模块仓库也推荐使用 pnpm。 | +| **编辑器** | [VS Code](https://code.visualstudio.com/) 或 [Cursor](https://cursor.com/) | 搭配官方扩展体验更佳。 | -### 必备与推荐 VS Code / Cursor 扩展 +### 推荐编辑器扩展 -- **SFMC Module**(官方专属扩展):提供一键生成新模块(`New Module`)、软链接挂接(`Link`)、实时监听热重载(`Start Watch`)与一键部署。 -- **ESLint**:启用 `@sfmc-bds/eslint-plugin` 规则,实施消息规范、SDK 导入路径与跨模块契约的静态检查。 -- **终端与调试器**:运行类型检查、构建与平台自检,并查看服务日志。 +- **SFMC Module**:官方 VS Code / Cursor 扩展,支持创建新模块、挂接软链接(Link)、文件监听热重载(Watch)与一键部署。 +- **ESLint**:启用 `@sfmc-bds/eslint-plugin`,在编写代码时实时检查消息格式、SDK 导入路径与权限声明。 -## 3. 选择你的开发路线 +## 3. 开发路径选择 - + -适合希望为服务器增添玩法功能的开发者: +适合开发具体的服务器玩法与功能扩展: -1. [模块开发全流程](./module-author.mdx) — 5 分钟脚手架、生命周期契约与 VS Code 扩展联调 -2. [API 速查手册 (Cheatsheet)](./cheatsheet.mdx) — 消息、数据库、配置与服务通信代码范例 -3. [验证与调试指南](./testing.md) — 了解静态检查、平台自检与 BDS 联调 -4. [ESLint 规则手册](./eslint.md) · [发布你的模块](./publish.md) — 质量核检与 npm 发布 +1. [模块开发手册](./module-author.mdx) — 初始化模块、本地联调与发布流程 +2. [API 速查手册 (Cheatsheet)](./cheatsheet.mdx) — 消息提示、数据库、配置与服务调用代码片段 +3. [验证与调试指南](./testing.md) — 静态检查、本地联调与 BDS 运行验证 +4. [ESLint 规则手册](./eslint.md) · [发布你的模块](./publish.md) — 代码规范与 npm 发布 ```bash -# 模块作者开发最小闭环 +# 1. 生成模块工程 pnpm dlx @sfmc-bds/create-module@latest cd my-feature && pnpm install && pnpm run typecheck -# 联调:通过 VS Code 扩展点击 "Link to SFMC Root" -# 或在运维服务器控制台直接挂接: +# 2. 挂接到本地测试服务器(也可在 VS Code 扩展点击 "Link to SFMC Root") sfmc mod install my-feature --from dir:D:/WorkSpace/my-feature --link sfmc mod enable my-feature sfmc mod reload ``` - + -适合参与 SFMC 框架本身演进的贡献者: +适合参与 SFMC 框架本身(SDK、CLI、db-server 等)的开发与维护: 1. 克隆主仓:`git clone https://github.com/DogeLakeDev/ScriptsForMinecraftServer.git` -2. 构建全套平台包:`pnpm install && pnpm run build` -3. 深入理解 [系统架构设计](./architecture.md) 与 [平台底层组件](./platform.md) +2. 安装依赖并构建:`pnpm install && pnpm run build` +3. 查阅 [系统架构设计](./architecture.md) 与 [平台核心开发](./platform.md) 4. 遵循 [贡献指南](./contributing.md) 与 [代码工程约定](./conventions.md) 提交 PR -## 4. 核心章节导航索引 +## 4. 文档导航 -| 章节手册 | 核心指引内容 | +| 章节 | 说明 | | :--- | :--- | -| **[模块开发手册](./module-author.mdx)** | 快速脚手架、扩展联动、命名约定与联调流程 | -| **[API 速查手册 (Cheatsheet)](./cheatsheet.mdx)** | 生命周期、Msg 消息、SQLite、配置与跨模块服务代码片段 | -| **[验证与调试策略](./testing.md)** | 模块作者 Watch 热联调、平台自检与真机验收 | -| **[Manifest 契约规格](./manifest.md)** | `manifest.json` v2/v3 字段定义与权限声明标准 | -| **[构建与打包管线](./build-pipeline.md)** | 行为包自动化装配、esbuild 转译流水线 | -| **[ESLint 约定与静态检查](./eslint.md)** | 平台专属 ESLint 插件与规约 | -| **[发布你的模块](./publish.md)** | npm 发布规范与向官方索引仓库提交 PR | -| **[系统架构设计](./architecture.md)** | 跨进程分层、启动序时序图与通信协议 | -| **[平台核心开发](./platform.md)** | SDK、db-server、CLI 与 devkit 组件演进 | -| **[代码工程约定](./conventions.md)** | 统一代码规范、Prettier 规则与安全边界 | -| **[附加包更新技术专题](./pack-update.md)** | CurseForge 远程源探测与版本算法底层实现 | +| **[模块开发手册](./module-author.mdx)** | 脚手架使用、目录结构、命名规范与本地联调 | +| **[API 速查手册 (Cheatsheet)](./cheatsheet.mdx)** | 常见 API 代码片段(消息、SQLite、配置、跨模块 RPC) | +| **[验证与调试策略](./testing.md)** | 静态检查、Watch 热重载与 BDS 运行验收 | +| **[Manifest 契约规范](./manifest.md)** | `manifest.json` 字段说明、权限语法与服务声明 | +| **[构建与打包管线](./build-pipeline.md)** | 行为包组装、esbuild 转译与文件注入逻辑 | +| **[ESLint 约定与静态检查](./eslint.md)** | `@sfmc-bds/eslint-plugin` 规则说明与配置 | +| **[发布你的模块](./publish.md)** | npm 发布与登记到官方索引仓库 | +| **[系统架构设计](./architecture.md)** | 进程模型、SAPI 生命周期与通信时序 | +| **[平台核心开发](./platform.md)** | SDK、db-server、CLI 等平台包架构与自检 | +| **[代码工程约定](./conventions.md)** | 命名规范、Prettier 规则与代码边界约定 | +| **[附加包更新技术专题](./pack-update.md)** | CurseForge 远程源探测与版本比较算法 | diff --git a/docs/zh/dev/manifest.md b/docs/zh/dev/manifest.md index b3c38745..ec450ff7 100644 --- a/docs/zh/dev/manifest.md +++ b/docs/zh/dev/manifest.md @@ -1,49 +1,49 @@ # Manifest 契约规范 -在 SFMC 模块化架构中,位于 `sapi/manifest.json` 的清单文件是模块的**核心契约(Contract)**。它向系统声明了该模块的唯一标识、依赖拓扑、数据库访问权限与跨模块服务(RPC)接口。 - -无论是平台编排引擎、`db-server` 鉴权中心,还是 VS Code 开发扩展,均严格以该契约作为静态校验与运行时隔离的依据。 +模块的 `sapi/manifest.json` 文件用于声明模块的基本信息、依赖关系、权限以及对外提供的 RPC 服务。平台 CLI、`db-server` 与开发扩展均基于该清单进行模块校验与加载。 :::tip Schema 绑定与智能提示 -在模块的 `sapi/manifest.json` 首行配置 `$schema`,可在编辑器中享受实时的语法补全与合法性校验: +在模块的 `sapi/manifest.json` 首行配置 `$schema`,可在编辑器中获取字段补全与校验: ```json "$schema": "https://raw.githubusercontent.com/DogeLakeDev/ScriptsForMinecraftServer/main/modules/sdk/@sfmc-sdk/schemas/sapi-manifest.v2.schema.json" ``` -v3 语义元数据目前没有公开的 JSON Schema,v3 示例不设置 `$schema`。若第三方 Bedrock 插件误报其不是原版 BP 清单,可安全忽略或将该路径加入其诊断忽略列表。 +v3 语义元数据目前没有公开的 JSON Schema,使用 v3 时可不设置 `$schema`。若第三方基岩版开发插件误将其识别为原版行为包 manifest 报错,可将该路径加入忽略列表。 ::: -## 1. 核心契约字段一览(v2 基线) +## 1. 核心字段说明(v2 基线) -| 字段名称 | 类型 | 必填 | 规约与说明 | +| 字段名称 | 类型 | 必填 | 说明 | | :--- | :--- | :---: | :--- | -| `schemaVersion` | `number` | **✓** | 契约版本号。基线模块为 `2`;包含 `semantic` 语义扩展块时填 `3`。 | -| `id` | `string` | **✓** | 模块全局唯一标识符(小写 kebab-case,无强制前缀;如 `land`、`feature-land` 均可)。 | -| `name` | `string` | **✓** | 模块的用户可读中文名称(如 `领地保护`、`通用经济`)。 | -| `configKey` | `string` | **✓** | 模块私有配置键(下划线命名),映射到 `/configs/.json`。 | -| `enabledByDefault` | `boolean` | ✕ | 安装时是否默认写入启用(缺省 `true`)。 | -| `canDisable` | `boolean` | ✕ | 是否允许 CLI/API 写入禁用(缺省 `true`;下次冷启动生效)。平台关键模块可显式设为 `false`。 | -| `requires` | `string[]` | **✓** | 该模块强依赖的前置模块 ID 列表。缺失前置时将被装载闸门阻断。 | -| `permissions` | `string[]` | **✓** | 模块向平台声明所需申请的底层资源权限节点列表(见下文语法规则)。 | -| `services.provides` | `ServiceEntry[]` | **✓** | 该模块向外部其它模块主动开放调用的 RPC 服务接口清单。 | -| `services.requires` | `string[]` | **✓** | 该模块需要消费调用的外部服务名称集合。 | -| `notes` | `string` | ✕ | 模块备注信息、设计说明或作者备忘。 | - -## 2. 权限声明语法规约(Permissions) - -为了实现微内核级的数据安全与邻居隔离,模块不能任意读写整个 SQLite 数据库。`db-server` 会根据此字段为模块下发带受限权限范围的 Bearer Token: - -| 权限语法模式 | 范例 | 权限授予范围说明 | +| `schemaVersion` | `number` | **✓** | 契约版本号。基线模块为 `2`;包含 `semantic` 扩展块时填 `3`。 | +| `id` | `string` | **✓** | 模块唯一标识符(小写 kebab-case,如 `teleport`、`feature-teleport` 均可)。 | +| `name` | `string` | **✓** | 模块显示名称(如 `领地保护`、`通用经济`)。 | +| `configKey` | `string` | **✓** | 模块专属配置键(下划线命名),映射到 `/configs/.json`。 | +| `requires` | `string[]` | **✓** | 该模块强依赖的前置模块 ID 列表。缺失前置依赖时将阻断加载。 | +| `permissions` | `string[]` | **✓** | 模块申请的资源权限列表(如数据库表读写权限,见下文)。 | +| `services.provides` | `ServiceEntry[]` | **✓** | 模块对外开放调用的 RPC 服务接口列表。 | +| `services.requires` | `string[]` | **✓** | 模块需要调用的外部服务名称集合。 | +| `notes` | `string` | ✕ | 模块备注或说明。 | + +:::tip 模块启停策略 +模块清单不再支持声明 `canDisable` 与 `enabledByDefault`。新安装的模块默认启用并允许服主禁用,启停由服主通过 `sfmc mod enable` 与 `sfmc mod disable` 控制并记录在 `module-lock.json` 中。 +::: + +## 2. 权限声明语法(Permissions) + +为保证数据安全,模块无法随意读写全部 SQLite 数据。`db-server` 会根据声明的权限生成带受限范围的访问 Token: + +| 权限模式 | 示例 | 授权范围说明 | | :--- | :--- | :--- | -| `db:read:` | `db:read:wallets` | 允许对指定的 SQLite 数据表执行 `SELECT` 读取。 | -| `db:write:
` | `db:write:wallets` | 允许对指定的数据表执行 `INSERT`、`UPDATE`、`DELETE` 等变更。 | -| `db:read:*` / `db:write:*` | `db:write:*` | **通配符全局表权限**。仅限高度特权模块申请,常规业务模块严禁滥用。 | -| `config:read:` | `config:read:economy` | 允许通过 SDK 读取该配置命名空间。 | +| `db:read:
` | `db:read:wallets` | 允许对指定的 SQLite 表执行 `SELECT` 查询。 | +| `db:write:
` | `db:write:wallets` | 允许对指定的 SQLite 表执行 `INSERT`、`UPDATE`、`DELETE`。 | +| `db:read:*` / `db:write:*` | `db:write:*` | **通配符全局表权限**。仅限高度特权核心模块申请,常规业务模块禁止滥用。 | +| `config:read:` | `config:read:economy` | 允许通过 SDK 读取该配置。 | | `config:write:` | `config:write:economy` | 允许通过 SDK 运行时修改并持久化该配置。 | | `service:` | `service:economy.transfer` | 允许发起跨模块 RPC 调用目标服务。 | ## 3. 跨模块服务声明(Services RPC) -模块间不得直接通过全局对象或相对路径导入调用,必须通过强契约的 RPC 机制解耦: +模块之间禁止直接通过相对路径相互导入,需通过 RPC 机制调用: ```json title="sapi/manifest.json 中的 services 片段" { @@ -77,12 +77,12 @@ v3 语义元数据目前没有公开的 JSON Schema,v3 示例不设置 `$schem } ``` -- **全局唯一性**:所有已启用模块中,`provides` 的服务 `name` 必须全局唯一,出现同名注册将触发装载异常。 -- **依赖自闭合**:所有列在 `requires` 中的服务,必须能够被某个已激活模块的 `provides` 闭环解析;若存在悬空依赖,系统将在开服前报警。 +- **服务名称唯一**:所有已启用模块中,`provides` 的服务 `name` 必须全局唯一,出现同名会报错。 +- **依赖完整性**:所有声明在 `requires` 中的服务,必须存在已启用的提供方模块,否则启动时报错。 -## 4. Manifest v3 语义化元数据扩展(可选) +## 4. Manifest v3 语义元数据(可选) -若将 `schemaVersion` 声明为 `3`,可额外提供 `semantic` 对象,为扩展开发工具、UI 控制面板与可视化调试提供更丰富的语义镜像: +将 `schemaVersion` 设为 `3` 时,可额外配置 `semantic` 对象,为开发扩展、控制面板提供模块行为描述: ```json title="sapi/manifest.json (v3 示例)" { @@ -127,17 +127,25 @@ v3 语义元数据目前没有公开的 JSON Schema,v3 示例不设置 `$schem } ``` -## 5. 废弃的 v1 历史字段禁令 +## 5. 已废弃与停用字段 + +以下字段已被严格禁用,如果在 `manifest.json` 中声明会导致校验失败并阻断加载: + +### 启停控制字段(已停用) +- ❌ `canDisable` +- ❌ `enabledByDefault` + +**说明**:模块启停完全由服主通过 CLI 或管理接口控制,写入 `module-lock.json`。模块作者无需也不能在清单中强制指定启停策略。如果清单中包含这两个字段,SDK 校验和 `db-server` 均会报错并拒绝启动,升级或新建模块时请直接删除。 -在旧版(v1 实验版)中曾出现的以下字段已被新版架构彻底废弃并禁止使用: +### v1 历史字段(已废弃) - ❌ `routes`、`tables`、`migrations`、`seeds`、`handlers`、`events` -一旦在 manifest 中出现上述遗留字段,`sfmc mod verify` 将抛出合规性错误。数据表的创建请统一收敛至模块主入口的 `lifecycle.init()` 阶段,通过 `db.defineTable` 或 `db.execute` 声明式完成。 +**说明**:数据表的创建已统一收敛至模块主入口 `lifecycle.init()` 阶段,通过 `db.defineTable` 声明式完成。 -## 6. 开服前自动化校验逻辑 +## 6. 启动前校验规则 -在拉起 BDS 前,`db-server` 与装载闸门会遍历已安装模块的 manifest 实施严格拓扑校验: -1. **重复 ID 检测**:确保无任何模块标识冲突。 -2. **循环依赖拓扑排序(Cycle Detection)**:通过拓扑排序算法检测是否存在循环前置依赖(如 A 依赖 B,B 依赖 A)。 -3. **未满足的服务接口(Dangling Services)**:扫描所有声明在 `services.requires` 中的条目,确保其提供方模块已处于启用状态。 -4. **越权请求核查**:根据模块类型限制通配符权限,确保各模块在受限沙盒内安全运行。 +在启动 BDS 之前,平台会自动校验已安装模块的 manifest: +1. **模块 ID 唯一性**:检查是否存在重复的模块 ID。 +2. **依赖拓扑检查**:解析模块依赖关系,检查是否存在循环依赖。 +3. **服务提供方检查**:检查 `services.requires` 中声明的服务是否有对应的启用模块提供。 +4. **权限范围检查**:检查通配符权限是否合规。 diff --git a/docs/zh/dev/module-author.mdx b/docs/zh/dev/module-author.mdx index 2ed98b69..5c8350e6 100644 --- a/docs/zh/dev/module-author.mdx +++ b/docs/zh/dev/module-author.mdx @@ -1,125 +1,126 @@ -# 业务模块开发全流程手册 +# 业务模块开发指南 -本手册面向 SFMC 业务模块开发者。读完本篇,你将掌握从**新建模块仓库、规范化命名、静态检查与真机热重载联调,到最终发布至 npm 与官方索引**的完整工程化闭环。 +本文介绍如何开发 SFMC 业务模块,涵盖脚手架初始化、目录结构、命名规范、本地联调、Manifest 配置以及发布流程。 -## 1. 快速脚手架:5 分钟初始化模块 +## 1. 快速开始 -推荐通过官方脚手架一键生成标准的作者独立仓库: +使用官方脚手架初始化模块仓库: ```bash -# 交互式初始化向导 pnpm dlx @sfmc-bds/create-module@latest ``` -向导将引导您填写并配置: -- **模块 ID(kebab-case)** -- **显示名称(Display Name)** -- **npm Scope**:社区模块建议使用个人或团队 scope(如 `@alice`) -- **可选能力选型**:是否开箱包含 SQLite 持久化支持(`db`) +向导将提示配置以下内容: +- **模块 ID(kebab-case)**:如 `teleport`、`economy` +- **显示名称(Display Name)**:如 `通用经济` +- **npm Scope**:个人或团队 scope(如 `@alice`) +- **功能选型**:是否包含 SQLite 数据库支持 -:::tip 图形化创建 -在 VS Code 或 Cursor 中安装官方扩展 **「SFMC Module」** 后,在命令面板(`Ctrl+Shift+P`)中执行 `SFMC: New Module`,亦可唤起相同脚手架引擎生成工程。 +:::tip 编辑器支持 +在 VS Code 或 Cursor 中安装扩展 **SFMC Module** 后,也可以通过命令面板(`Ctrl+Shift+P`)执行 `SFMC: New Module` 生成工程。 ::: -进入新生成的工程目录并安装依赖、检查类型: +进入模块目录并安装依赖、执行类型检查: ```bash cd my-feature pnpm install && pnpm run typecheck ``` -## 2. 工程规范与命名矩阵 +## 2. 命名规范 -为确保模块在全生态中的命名唯一性与一致性,请严格遵循以下规范矩阵: +为避免模块冲突,模块各位置的命名约定如下: -| 层次维度 | 命名规约 | 示例 | 作用与约束 | +| 场景 | 命名格式 | 示例 | 说明 | | :--- | :--- | :--- | :--- | -| **文件夹 / 安装 ID** | 短名,小写连字符(kebab-case) | `teleport`、`economy` | 运维机安装和执行命令的唯一短名(如 `sfmc mod install teleport`)。 | -| **npm 包名(社区)** | `@/sfmc-module-` | `@alice/sfmc-module-teleport` | 社区公开发布至 npm Registry 的包名。 | -| **npm 包名(官方)** | `@sfmc-bds/module-` | `@sfmc-bds/module-economy` | 平台核心团队维护的官方模块。 | -| **Manifest 模块 ID** | `feature-` 或 `core-` | `feature-teleport` | 在 SAPI 虚拟机内部的唯一模块标识符。 | +| **安装短名 / 文件夹** | 小写连字符(kebab-case) | `teleport`、`economy` | CLI 安装与管理时使用的短名称(如 `sfmc mod install teleport`)。 | +| **社区 npm 包名** | `@/sfmc-module-` | `@alice/sfmc-module-teleport` | 发布至 npm 官方源的包名。 | +| **官方 npm 包名** | `@sfmc-bds/module-` | `@sfmc-bds/module-economy` | 官方维护的核心业务模块。 | +| **Manifest 模块 ID** | `feature-` 或 `core-` | `feature-teleport` | SAPI 运行时中的唯一模块标识。 | | **配置命名空间** | 下划线命名(snake_case) | `teleport`、`my_feature` | 对应 `/configs/.json`。 | -## 3. 作者开发标准工作流(Developer Workflow) +## 3. 开发工作流 -SFMC 将模块开发划分为 5 个清晰阶段: +开发业务模块的常规流程如下: ```mermaid flowchart LR - A[1. 脚手架建仓
pnpm dlx] --> B[2. 类型与语法检查
typecheck & lint] - B --> C[3. 挂接工作区
--link 软链接] + A[1. 初始化模块
pnpm dlx] --> B[2. 类型与语法检查
typecheck & lint] + B --> C[3. 挂接到测试服
--link 软链接] C --> D[4. 实时监听
Watch & Hot Reload] - D --> E[5. 生态发布
pnpm publish & 官方索引 PR] + D --> E[5. 发布上线
pnpm publish & 索引登记] ``` -### ① 编写与静态检查(Type Safety & Lint) -在本地编码阶段,充分利用 TypeScript 强类型与官方 ESLint 规则保障质量: +### 1. 编写与静态检查 +编码过程中通过 TypeScript 与 ESLint 保障代码质量: ```bash -pnpm run typecheck # 验证 SAPI 类型约束 -pnpm run lint # 检查 Msg 消息助手、SDK 导入与权限约定 +pnpm run typecheck # 检查 SAPI 类型与 SDK 契约 +pnpm run lint # 检查消息规范、导入路径与权限规则 ``` -### ② 挂接至本地开服目录(Link) -将作者仓直接软链接到你的本地测试服务器(SFMC_ROOT): +### 2. 挂接到本地测试服务器(Link) +将本地模块软链接到测试服务器(SFMC_ROOT)进行联调: ```bash -# 方式 A:在 VS Code 扩展点击 "SFMC: Link to SFMC Root" -# 方式 B:在服务端执行 CLI 命令 +# 方式 A:在 VS Code 扩展中点击 "SFMC: Link to SFMC Root" +# 方式 B:在服务端终端执行命令 sfmc mod install my-feature --from dir:D:/WorkSpace/my-feature --link sfmc mod enable my-feature ``` -### ③ 实时监听与热重载(Watch & Reload) -在 VS Code / Cursor 中点击扩展栏的 **`SFMC: Start Watch`**。 -每次你在 `sapi/src/` 中保存代码时,`@sfmc-bds/devkit` 会在毫秒内完成转译增量部署,并自动通知 BDS 刷新行为包,游戏内无感热生效! +### 3. 实时监听与热重载(Watch & Reload) +在 VS Code / Cursor 中点击扩展侧边栏的 **`SFMC: Start Watch`**。每次保存 `sapi/src/` 中的代码,扩展会自动增量编译并通知 BDS 热重载。 -### ④ 发布至 npm 与官方生态 -代码验证完毕后,正式发布上线: +### 4. 发布到 npm 与社区索引 +测试验证完成后,即可发布上线: ```bash -# 发布至 npm(要求公共包) +# 发布到 npm(需公开访问) pnpm publish --access public -# 向官方索引 sfmc-modules 的 index.json 提交 PR 登记你的模块 +# 向 sfmc-modules 仓库的 index.json 提交 PR 登记模块 ``` -## 4. 作者仓标准文件结构 +## 4. 模块目录结构 ```text my-feature/ -├── package.json # 声明依赖与 exports +├── package.json # 依赖声明与 npm exports ├── .vscode/ │ ├── extensions.json # 推荐扩展(SFMC Module, ESLint 等) -│ └── settings.json # 绑定 JSON Schema +│ └── settings.json # 编辑器配置 ├── eslint.config.js # 集成 @sfmc-bds/eslint-plugin ├── sapi/ -│ ├── manifest.json # 模块契约声明(v2 / v3) +│ ├── manifest.json # 模块契约声明 │ ├── tsconfig.json # 面向 SAPI 环境的 TypeScript 配置 │ └── src/ -│ └── index.ts # 模块业务逻辑主入口(ModuleRegistry.register) -└── package.json +│ └── index.ts # 模块入口(ModuleRegistry.register) ``` -### SDK 模块化导入路径指南 +### SDK 常用导入路径 -| 导入路径 | 导出功能 | 使用场景 | +| 导入路径 | 导出内容 | 使用场景 | | :--- | :--- | :--- | -| **`@sfmc-bds/sdk/module-loader`** | `ModuleRegistry`、`ConfigManager` | 模块根入口注册与生命周期定义。 | -| **`@sfmc-bds/sdk/sapi/runtime`** | `Command`、`Msg`、`Permission`、`MenuNavigator` | 聊天命令、规范消息展示、权限节点注册与表单菜单。 | -| **`@sfmc-bds/sdk/sapi/db`** | `db.defineTable`、`db.select`、`db.insert`、`db.tx` | SQLite 透明持久化与多模块分布式事务。 | -| **`@sfmc-bds/sdk/sapi/config`** | `config.get`、`config.set` | 读取并监听本模块私有配置文件。 | -| **`@sfmc-bds/sdk/sapi/service`** | `service.provide`、`service.call` | 跨模块暴露远程 RPC 接口或调用其他模块能力。 | - -:::warning 注意:杜绝违规直连 -严禁在 SAPI 模块内使用原生 `fetch` 或 `http` 直接请求 `127.0.0.1:3001`。所有数据库读写与服务通信必须通过 SDK 封装的 `db.*`、`service.*` 接口进行,SDK 会自动完成请求上下文封装与 Bearer Token 鉴权注入。 +| **`@sfmc-bds/sdk/module-loader`** | `ModuleRegistry`、`ConfigManager` | 模块入口注册与生命周期定义。 | +| **`@sfmc-bds/sdk/sapi/runtime`** | `Command`、`Msg`、`Permission`、`MenuNavigator` | 聊天命令、规范消息输出、权限节点与表单导航。 | +| **`@sfmc-bds/sdk/sapi/db`** | `db.defineTable`、`db.select`、`db.insert`、`db.tx` | SQLite 数据读写与跨模块事务。 | +| **`@sfmc-bds/sdk/sapi/config`** | `config.get`、`config.set` | 读取并监听模块专属配置文件。 | +| **`@sfmc-bds/sdk/sapi/service`** | `service.provide`、`service.call` | 跨模块暴露或调用 RPC 服务。 | + +:::warning 注意 +SAPI 模块内禁止直接使用原生 `fetch` 请求 `127.0.0.1:3001`。所有数据库操作与跨模块通信必须通过 SDK 提供的 `db.*` 和 `service.*` 进行,SDK 会自动处理请求封装与 Bearer Token 鉴权。 ::: -## 5. Manifest v3 语义化元数据契约 +## 5. Manifest 配置与语义元数据 -目前没有公开的 v3 JSON Schema;下面的示例不设置 `$schema`。 +在 `sapi/manifest.json` 中声明模块的基础标识、权限和依赖。推荐声明 **v3 语义元数据(`semantic` 字段)**,便于工具和控制面板识别模块行为: -在 `sapi/manifest.json` 中,除了基础的模块 ID 和依赖外,推荐声明 **v3 语义化元数据(`semantic` 块)**。它能够帮助编辑器、静态分析工具与运维面板深刻理解该模块的行为特征: +:::tip 启停控制说明 +模块 manifest 不再支持 `canDisable` 与 `enabledByDefault` 字段(若存在会导致校验报错)。新安装的模块默认启用,服主通过 `sfmc mod enable ` 与 `sfmc mod disable ` 管理启停,在下次 BDS 冷启动时生效;升级时会自动保留已有的启停状态。 +::: + +目前 v3 尚无独立公开的 JSON Schema,示例中不设置 `$schema`: ```json title="sapi/manifest.json" { @@ -149,7 +150,7 @@ my-feature/ "publicApi": [ { "symbol": "transfer", - "description": "跨玩家安全转账(支持事务回滚)", + "description": "跨玩家转账接口", "params": [ { "name": "fromPlayer", "type": "string", "required": true }, { "name": "toPlayer", "type": "string", "required": true }, @@ -162,20 +163,23 @@ my-feature/ } ``` -### 语义字段解析 +### 语义字段说明 + +- **`configKeys`**:该模块读取或监听的配置键列表。 +- **`events.emits` / `listens`**:该模块触发或监听的事件名。 +- **`dbTables`**:该模块创建和使用的 SQLite 数据表与核心字段。 +- **`publicApi`**:对外暴露的公共 API 接口描述,供下游调用者参考。 -- **`configKeys`**:声明该模块会读取或监听的配置键集合。 -- **`events.emits` / `listens`**:声明该模块主动触发的全局事件与监听的原生/跨模块事件路径。 -- **`dbTables`**:描述该模块在 SQLite 中注册的数据表与核心列。 -- **`publicApi`**:对外暴露的强类型 API 接口说明,为下游调用者提供签名参考。 +### 版本兼容说明 -### 向下兼容性保障 -- **零破坏性升级**:v3 对 v2 完全向下兼容。旧模块保留 `schemaVersion: 2` 即可平滑运行,系统会自动将其平滑升维为缺省 `semantic` 的 v3 格式。 -- **类型宽容度**:即使在 `semantic` 中声明了可选错误类型,校验引擎仅会在控制台打印警告提示,绝对不会粗暴阻断模块的启动加载。 +- **兼容 v2**:`schemaVersion: 2` 的模块仍可正常运行,系统加载时会自动填充缺省的 `semantic` 结构。 +- **容错处理**:若 `semantic` 中可选字段格式不规范,系统仅在控制台输出警告,不会阻断模块加载。 +- 更多字段规则与权限语法请参考 [Manifest 契约规范](./manifest.md)。 -## 6. 下一步指引 +## 6. 相关文档 -- 📚 **核心 API 速查**:查阅 [API 速查手册 (Cheatsheet)](./cheatsheet.mdx) 获取最常用的代码片段。 -- 🧪 **测试与调试策略**:查阅 [测试与调试策略](./testing.md) 掌握本地类型校验与真机 Watch 热重载的最佳实践。 -- 🛡️ **代码质量规范**:查阅 [ESLint 规则说明](./eslint.md) 消除常见的安全隐患与反模式。 -- 🚀 **发布至 npm**:查阅 [发布你的模块](./publish.md) 了解版本打标与上架规范。 +- [API 速查手册 (Cheatsheet)](./cheatsheet.mdx) — 常用 API 接口与代码范例 +- [Manifest 契约规范](./manifest.md) — 清单字段完整定义与权限语法 +- [验证与调试策略](./testing.md) — 静态检查、本地联调与 BDS 运行验收 +- [ESLint 规则说明](./eslint.md) — 代码风格与边界约束 +- [发布你的模块](./publish.md) — 版本打标与 npm 发布指南 diff --git a/docs/zh/dev/platform.md b/docs/zh/dev/platform.md index 1fbc4b86..aa1a0b93 100644 --- a/docs/zh/dev/platform.md +++ b/docs/zh/dev/platform.md @@ -1,37 +1,37 @@ # 平台核心开发指南 -本文档面向希望参与 SFMC 平台核心组件(SDK、`db-server`、`qq-bridge`、`bds-tools`、CLI 或 CI 流水线)建设的架构师与贡献者。 +本文介绍 SFMC 平台核心包(SDK、`db-server`、`qq-bridge`、`bds-tools`、CLI 等)的架构设计、本地构建自检与核心子系统。 -:::tip 业务模块开发者请绕行 -如果您旨在开发具体的 Minecraft 玩法功能(如领地、经济、菜单等),请直接查阅 [业务模块开发全流程手册](./module-author.mdx),业务模块无需改动平台主仓源码。 +:::tip 业务模块开发 +如果目标是开发服务器玩法模块(如经济、领地等),请直接查阅 [业务模块开发指南](./module-author.mdx),无需修改主仓源码。 ::: -## 1. 核心设计原则:包的高内聚与独立性 +## 1. 包设计原则 -SFMC 的底层包遵循严格的微内核与单向依赖原则: +SFMC 的平台包遵循单向依赖与高内聚原则: -- **能力沉淀于平台包**:持久化数据库(`db-server`)、QQ 互通(`qq-bridge`)、BDS 版本管理与附加包更新(`bds-tools`)、通用类型与运行时(`@sfmc-bds/sdk`)均具备完全独立的生命周期,必须能够在脱离 CLI 的情况下被独立引用与执行。 -- **CLI 仅作交互与编排壳**:`@sfmc-bds/cli` 的定位是用户界面(REPL)与任务 Supervisor。**严禁任何服务包或 SDK 反向依赖 CLI**。 +- **核心能力沉淀于平台包**:数据服务(`db-server`)、QQ 互通(`qq-bridge`)、BDS 版本管理与附加包工具(`bds-tools`)、运行时与类型(`@sfmc-bds/sdk`)均具备独立的生命周期,可在脱离 CLI 的情况下被独立引用。 +- **CLI 作为编排与交互层**:`@sfmc-bds/cli` 负责用户交互(REPL)与多进程管理,底层服务包与 SDK 禁止反向依赖 CLI。 - **目录约定**: - - `packages/*`:平台级基础核心包。 - - `modules/packages/*`:业务模块运行镜像目录(平台核心代码与业务功能物理隔离)。 + - `packages/*`:平台基础核心包。 + - `modules/packages/*`:运行时业务模块安装目录,与平台源码独立。 -## 2. 平台全套构建与冒烟自检 +## 2. 构建与本地自检 -主仓基于 `pnpm` workspaces 搭建。克隆主仓后,执行全量依赖安装、编译与自检: +主仓基于 pnpm workspaces 搭建。克隆主仓后,执行全量依赖安装与构建: ```bash # 全局依赖安装与全量并行构建 pnpm install && pnpm run build -# 运行平台全套冒烟与格式一致性检验 +# 运行平台集成自检 pnpm run verify ``` -### 单包针对性编译与热联调 +### 单包编译与联调 ```bash -# 仅构建指定子包 +# 构建指定子包 pnpm --filter @sfmc-bds/sdk run build pnpm --filter @sfmc-bds/db-server run build pnpm --filter @sfmc-bds/cli run build @@ -40,74 +40,74 @@ pnpm --filter @sfmc-bds/cli run build cd packages/db-server && pnpm run dev ``` -:::important SDK 级联变更纪律 -若修改了 `@sfmc-bds/sdk` 中的导出或类型契约: +:::important SDK 变更注意事项 +若修改了 `@sfmc-bds/sdk` 中的导出或类型定义: 1. 先重新编译 SDK:`pnpm --filter @sfmc-bds/sdk run build` -2. 级联编译依赖它的各个工作区包(`db-server`、`cli` 等) -3. 重新运行 `pnpm run verify` 确保类型定义与 TypeDoc 生成一致 +2. 重新编译依赖它的工作区包(`db-server`、`cli` 等) +3. 运行 `pnpm run verify` 确保类型检查与文档同步一致 ::: -## 3. 核心子系统剖析 +## 3. 核心子系统介绍 -### ① `packages/db-server`(持久化中枢) -- **职责**:基于 Node.js 22 内置的 `node:sqlite` 原生驱动,为原生 SAPI 与外部服务提供 HTTP REST 接口、分布式事务(`tx-runner`)与鉴权校验。 +### `packages/db-server`(数据服务) +- **职责**:基于 Node.js 内置的 `node:sqlite` 驱动,为 SAPI 与伴生服务提供 HTTP REST 接口、分布式事务(`tx-runner`)与 Token 鉴权校验。 - **入口**:`packages/db-server/src/index.ts`,路由分散在 `routes/` 目录下(`db-routes.ts`、`service-routes.ts`、`config.ts`)。 -- **验证**:运行目标包 `typecheck`,再用平台 `verify` 检查数据库接口。 +- **验证**:运行该包的 `typecheck`,并通过 `pnpm run verify` 检查相关接口。 -### ② `packages/qq-bridge`(多端通信桥) -- **职责**:支持腾讯 QQ 开放平台 WebSocket Gateway(官方机器人)与 OneBot 11(LLBot)双后端,实现消息的双向转换与事件推群。 -- **验证**:运行目标包 `typecheck` 与 `build`,再检查实际消息链路。 +### `packages/qq-bridge`(QQ 互通桥接) +- **职责**:支持 QQ 开放平台 WebSocket 网关(官方机器人)与 OneBot 11(LLBot)双后端,实现群聊与游戏消息互通。 +- **验证**:运行该包的 `typecheck` 与 `build`,并通过真实环境测试消息链路。 -### ③ `packages/bds-tools`(BDS 运维、版本管理与附加包装配) +### `packages/bds-tools`(BDS 管理与更新) - **职责**: - - **BDS 生命周期管理**(`bds-manager.ts`):子进程/外部进程状态探测、标准输入转发、崩溃自愈与优雅停机。 - - **自动化更新与灾难恢复**(`check-update.ts`、`rollback.ts`):Mojang 官方发行版自动探测、增量备份、`preserve` 文件白名单保护与失败原子回滚。 - - **配置合规与多语言辅助**(`server-properties.ts`、`server-properties-i18n.ts`):基于属性感知 AST 实现 `server.properties` 注释的中文结构化本地化,保障 EULA 遥测合规(`emit-server-telemetry`),绝不破坏服主配置键值。 - - **世界附加包与依赖仲裁**(`world-packs.ts`、`dependency-negotiator.ts`、`pack-update/`):行为包与资源包动态组装部署、CurseForge 上游自动升级探测、`level.dat` 实验性开关(GameTest / Beta APIs)自动激活。 -- **验证**:运行目标包 `typecheck` 与 `build`,涉及 BDS 时在测试世界验收。 - -### ④ `packages/cli`(编排主控与 REPL) -- **职责**:实现开服交互控制台、多服务进程生命周期监控、命令分发与日志多路复用。 + - **BDS 生命周期管理**(`bds-manager.ts`):子进程与外部进程探测、控制台输入转发、进程监控与优雅停机。 + - **版本更新与备份**(`check-update.ts`、`rollback.ts`):Mojang 官方版本检测、增量备份、白名单保护与失败回滚。 + - **配置文件维护**(`server-properties.ts`、`server-properties-i18n.ts`):解析 `server.properties` 注释并提供中文本地化,保持原有配置键值。 + - **附加包与依赖管理**(`world-packs.ts`、`dependency-negotiator.ts`、`pack-update/`):行为包与资源包动态组装部署、CurseForge 上游更新检测、`level.dat` 实验性开关配置。 +- **验证**:运行目标包的 `typecheck` 与 `build`,涉及 BDS 运行时需在测试世界验收。 + +### `packages/cli`(命令行与交互控制台) +- **职责**:提供控制台交互、多进程启停管理、命令路由与日志展示。 - **命令通道分类**(`command-surface.ts`): - - `both`:既支持交互式 REPL,又可在宿主 Shell 直接作为子命令执行(如 `start`、`stop`、`mod`)。 - - `repl`:仅在进入 `sfmc` 交互界面后可用。 - - `external`:仅限宿主 Shell 执行(如 `debug` 底层诊断命令)。 + - `both`:既支持交互控制台执行,又可在 Shell 中作为子命令执行(如 `start`、`stop`、`mod`)。 + - `repl`:仅在进入 `sfmc` 控制台后可用。 + - `external`:仅限 Shell 直接执行(如 `debug` 命令)。 -### ⑤ `packages/devkit` 与 `packages/sfmc-extension` -- `@sfmc-bds/devkit`:底层封装了模块脚手架、文件监听(Watch)与 esbuild 增量构建部署逻辑。 -- `sfmc-extension`:VS Code / Cursor 官方开发扩展,通过 IPC 调用 devkit 暴露的原子能力。 +### `packages/devkit` 与 `packages/sfmc-extension` +- `@sfmc-bds/devkit`:封装了模块脚手架、文件监听(Watch)与 esbuild 增量构建逻辑。 +- `sfmc-extension`:VS Code / Cursor 官方开发扩展,通过 IPC 调用 devkit 提供的能力。 -## 4. 提交 Pull Request 前的质量检查清单 +## 4. PR 提交前检查 -在向主仓推送分支或发起 PR 前,请确保依次通过以下检查: +在向主仓推送代码或提交 PR 前,请依次运行以下检查: ```bash -# 1. 编译全包制品 +# 1. 编译全包产物 pnpm run build -# 2. 静态语法与代码规范扫描 +# 2. 静态检查与类型检查 pnpm run lint pnpm run typecheck -# 3. 运行全仓自动化验证套件 +# 3. 运行集成自检 pnpm run verify ``` -GitHub Actions(`ootb.yml`)会在 Ubuntu 与 Windows 双平台上以 Node 22+ 运行上述自动化验证。 +GitHub Actions 会在 Linux 与 Windows 环境下以 Node 22+ 运行上述检查。 -## 5. SAPI 底层调试与 Sentry 异常上报 +## 5. 调试与日志 -在排查 SAPI 内部疑难异常时,可启用平台内置的 Debug 开关与 Sentry 遥测: +排查 SAPI 内部问题时,可使用内置的 Debug 开关与 Sentry 异常追踪: ```bash -# 查看当前调试与上报开关状态 +# 查看当前调试与上报状态 sfmc debug status -# 开启 SAPI 控制台底层详细调试日志 +# 开启 SAPI 控制台详细调试日志 sfmc debug enable -# 绑定 Sentry DSN 实现生产环境异常捕获 +# 绑定 Sentry DSN 进行异常追踪 sfmc debug sentry on --dsn ``` -底层实现依托 `@sfmc-bds/sdk/sapi/runtime` 中的 `debug` 模块。当检测到 `sfmc_debug: true` 时,会向控制台输出微秒级时序跟踪;当绑定了 Sentry DSN 时,未捕获的严重异常将自动上报云端便于追踪定位。 +底层实现依托 `@sfmc-bds/sdk/sapi/runtime` 中的 `debug` 模块。当启用 `sfmc_debug: true` 时,会向控制台输出详细时序日志;绑定 Sentry DSN 后,未捕获的严重异常将自动上报。 diff --git a/docs/zh/dev/publish.md b/docs/zh/dev/publish.md index 5db54b07..08b11250 100644 --- a/docs/zh/dev/publish.md +++ b/docs/zh/dev/publish.md @@ -1,43 +1,115 @@ # 发布你的模块 -把作者仓发到 npm,并登记到 [`sfmc-modules`](https://github.com/Tanya7z/sfmc-modules) 薄 index,供 `mod search` / `mod install` 发现。 +本文介绍如何将业务模块发布到 npm,并登记到社区索引 [`sfmc-modules`](https://github.com/Tanya7z/sfmc-modules),以便服主通过 `sfmc mod search` 与 `sfmc mod install` 安装。 -业务模块 **不走** 主仓 changesets。平台 `@sfmc-bds/*` 发包见文末。 -`sfmc mod publish` 已移除;请用扩展 `SFMC: Publish to npm`,或直接 `pnpm publish`(及可选 `gh` 开 index PR)。 +业务模块在各自独立的仓库中发布,不通过主仓的 Changesets 流水线(平台核心包 `@sfmc-bds/*` 的发版说明见文末附录)。 -## 发布前 +`sfmc mod publish` 历史命令已移除,请使用扩展命令 `SFMC: Publish to npm` 或直接执行 `pnpm publish`。 -| 检查 | 说明 | -| ------ | ------ | -| `private` | 勿为 `true` | -| `name` | `@/sfmc-module-` 或官方 `@sfmc-bds/module-` | -| `files` | 含 `sapi` | -| 类型检查 / lint | `pnpm run typecheck` 与 `pnpm run lint`;发布前按 [验证指南](./testing.md) 完成实际联调 | -| 发布权限 | npm registry 账号或 CI 发布凭据;官方 scope 另需组织权限 | +## 1. 发布前检查 + +| 检查项 | 要求说明 | +| :-------- | :------------------------------------------------------------------------------- | +| `private` | `package.json` 中的 `private` 不能为 `true`。 | +| `name` | 社区模块使用 `@/sfmc-module-`,官方模块使用 `@sfmc-bds/module-`。 | +| `files` | 必须包含 `sapi` 目录。 | +| 静态检查 | 必须通过 `pnpm run typecheck` 与 `pnpm run lint`,并完成本地测试验证。 | +| 发布权限 | 确保已登录具备该 scope 发布权限的 npm 账号。 | ```bash +# 发布正式版本(公开包) pnpm publish --access public -# 或按包上的 dist-tag: + +# 发布测试版本(dist-tag) pnpm publish --tag beta ``` -向薄 index 开 PR:在 `sfmc-modules` 仓库的 `index.json` 增加条目(`id` / `npm` / 版本说明)。可用 GitHub CLI 自行开 PR。 +:::tip 版本说明 +正式版默认发布到 npm 的 `latest` 标签;预发布版本建议指定 dist-tag(如 `@beta`)。 +::: + +## 2. 登记到社区索引 -:::tip 提示 -正式版默认使用 npm `latest`;只有预发布包才在安装命令中显式指定对应的 dist-tag(例如 `@beta`)。 +发布至 npm 后,在作者仓运行: -::: +```bash +sfmc mod submit --dry-run +sfmc mod submit +# 也可以指定模块目录 +sfmc mod submit ./my-module --dry-run +``` + +没有 CLI 时可安装 `@sfmc-bds/devkit`,使用 `sfmc-module-submit`;扩展提供 **SFMC: 提交到官方模块索引**,先打开 JSON 预览,再由作者选择提交。 +提交需要 `gh auth login` 或 `GH_TOKEN`。有索引仓写权限时自动在索引仓建 PR 分支;其他作者自动 fork、创建分支并开 PR。相同内容不重复提交,新版本复用已有待审 PR。 +`--no-fork` 可显式选择在目标索引仓建分支,需要该仓 Contents 和 Pull requests 写权限。提交路径不受账号名、仓库 owner 或模块 `official` 字段影响。 + +工具从 `package.json` 与 `sapi/manifest.json` 生成条目。SDK 范围来自 `peerDependencies.@sfmc-bds/sdk`,不能用开发依赖版本推断兼容性。 +在 `sfmc.registry.json` 中补充展示信息即可: + +```json +{ + "$schema": "https://cdn.jsdelivr.net/gh/DogeLakeDev/ScriptsForMinecraftServer@main/packages/devkit/schemas/registry-author.schema.json", + "name": "我的模块", + "description": "模块提供的实际功能", + "category": "utility", + "tags": ["管理"], + "authors": ["你的公开署名"] +} +``` + +配置只允许展示字段;ID、npm 包、版本、许可证、SDK 和依赖仍从契约读取。 +更新已有模块时,未在作者配置中覆盖的名称、介绍及未指定的分类、标签、作者署名沿用索引内容,避免发版丢失已整理的展示信息。 +工具检查公共 npm 的精确版本和元数据、最新索引 Schema、ID 来源、降版和依赖关系。 +首次收录默认第三方身份,已收录模块保留维护者审核的官方身份。 -## 发布后 +PR 只修改索引仓的 `modules/.json`。**不要手改或提交生成的 `index.json`**;合并后索引仓 Actions 自动构建聚合索引。 +不使用命令行的作者可填写索引仓的 **申请收录或更新模块** Issue 表单,机器人读取公共 npm 元数据并生成审核 PR。 -1. 确认 index 出现该模块的 `npm` 字段 -2. 在 SFMC 工作目录:`sfmc mod install ` -3. `mod enable` → `mod reload` +### 发布后自动提交 -官方模块可从主仓手动运行 `module-publish` 工作流。主仓现有 `SFMC_GITHUB_TOKEN` 没有模块仓创建 Git tag 的权限,因此正式发布时保持 `create_github_release=false`;npm 发布成功后,由有模块仓写权限的维护者在对应模块仓为指定 `commit` 创建 `v` 标签和 GitHub Release。补齐后再更新 `sfmc-modules` 索引。若以后为工作流配置了模块仓 Contents 写权限,可将 `create_github_release` 设为 `true`。 +官方主仓 `module-publish` 在实际 npm 发布成功后调用 `module-index-submit.yml`,dry-run 不提交。 +请在主仓配置 `REGISTRY_SUBMIT_TOKEN`,授予索引仓 Contents / Pull requests 写权限;建议使用 GitHub App 或专用维护者凭据。 +普通 `GITHUB_TOKEN` 的权限仅覆盖当前仓库,且它创建的 PR 通常不会触发后续 PR 工作流,因此这里使用独立凭据。 -命名与联调见 [模块开发](./module-author.md)。 +已获维护者授权的仓库可在发布 job 成功后复用入口: + +```yaml +submit-index: + needs: publish + uses: DogeLakeDev/ScriptsForMinecraftServer/.github/workflows/module-index-submit.yml@main + with: + module_repository: ${{ github.repository }} + module_ref: ${{ github.sha }} + secrets: + REGISTRY_SUBMIT_TOKEN: ${{ secrets.REGISTRY_SUBMIT_TOKEN }} +``` + +一般第三方作者使用一键命令或表单,无需官方仓写权限。 +提交工具只读取模块 JSON,不运行模块仓脚本。索引提交失败不会撤回已发布的 npm 版本;可手动运行 **Submit module registry PR** 重试,无需重新发布 npm。 + +PR 合并后,服主即可通过 CLI 检索并安装该模块: + +```bash +# 1. 检索模块 +sfmc mod search + +# 2. 安装并启用 +sfmc mod install +sfmc mod enable +sfmc mod reload +``` + +## 3. 官方模块发布说明 + +官方团队维护的模块可通过主仓的 `module-publish` 工作流手动触发。若工作流的 GitHub Token 未配置模块仓 Git Tag 权限,请在发布参数中保持 `create_github_release=false`,待 npm 发布完成后,由具备写权限的维护者在对应模块仓打 Tag。 + +模块命名与本地联调请查阅 [业务模块开发指南](./module-author.mdx)。 ## 附录:平台包发布(贡献者) -主仓 `@sfmc-bds/*` 走 changesets:`pnpm run changeset` → Version PR → `ci-release-packages`。详见 [贡献指南](./contributing.md)。 +主仓核心包(`@sfmc-bds/*`)采用 Changesets 管理版本: + +1. 运行 `pnpm run changeset` 生成变更说明。 +2. 合并至 `main` 分支后,CI 会自动创建 Version PR。 +3. 合并 Version PR 后自动触发 `ci-release-packages` 工作流完成 npm 发包。 + 详见 [贡献指南](./contributing.md)。 diff --git a/docs/zh/guide/addons.md b/docs/zh/guide/addons.md index afe42784..00ebf9aa 100644 --- a/docs/zh/guide/addons.md +++ b/docs/zh/guide/addons.md @@ -1,36 +1,36 @@ # 附加包管理(Add-ons) -在 Minecraft 基岩版生态中,服主常常需要引入社区现成的第三方**行为包(BP)**与**资源包(RP)**(如拔刀剑、载具系统、光影材质等)。 +在 Minecraft 基岩版中,服主常常需要引入社区第三方**行为包(BP)**与**资源包(RP)**(如玩法机制、载具系统、材质光影等)。 -SFMC 提供了专属的 **收件箱(Inbox)流水线** 与 **CurseForge 自动更新引擎**,通过 `packs`(或别名 `addon`)命令进行自动化生命周期编排。 +SFMC 提供了附加包导入目录与 CurseForge 更新支持,通过 `packs`(或别名 `addon`)命令管理生命周期。 :::tip 概念辨析:模块 vs 附加包 -**SFMC 业务模块(`modules/`)**:采用 TypeScript 编写的平台轻量级功能包,由 SFMC 动态编译聚合为单一行为包,与 Node 伴生服务深度互通。 -**第三方附加包(`packs/`)**:现成的 `.mcpack`、`.mcaddon` 或 `.zip` 游戏扩展包,由收件箱流水线直接分发进指定游戏世界的包目录中。 +- **SFMC 业务模块(`modules/`)**:采用 TypeScript 编写的平台轻量级功能包,由 SFMC 动态编译聚合为单一行为包,与 Node 伴生服务深度互通。 +- **第三方附加包(`packs/`)**:现成的 `.mcpack`、`.mcaddon` 或 `.zip` 游戏扩展包,由收件箱直接分发至指定游戏世界的包目录中。 ::: -## 1. 收件箱流水线架构 +## 1. 附加包目录结构 -服主无需手动解压或深入世界存档多层子目录。直接将下载好的扩展包放入 `/packs/` 即可: +将下载好的扩展包放入 `/packs/` 即可自动识别与分发: ```text /packs/ -├── inbox/ (即 packs/ 顶层) # 直接将 .mcpack / .mcaddon / .zip 丢在此处 +├── inbox/ (即 packs/ 顶层) # 直接将 .mcpack / .mcaddon / .zip 放入此处 ├── _done/ # 安装部署成功后的原始归档备份 ├── _failed/ # 格式损坏、缺少 manifest 或校验失败的包 -├── _trash/ # 卸载后的安全回收站(防止误删) -├── _build/ # SFMC 自身模块行为包的构建产物(请勿触碰) -└── pack-sources.json # 各附加包与 CurseForge 等上游更新源的绑定关系 +├── _trash/ # 卸载后的回收站目录 +├── _build/ # SFMC 模块行为包的构建产物(自动生成) +└── pack-sources.json # 各附加包与 CurseForge 等上游更新源的绑定映射 ``` ```mermaid flowchart TD - Drop["1. 将附加包投入 /packs/"] --> Scan["2. 执行 packs scan 扫描与校验"] + Drop["1. 将附加包放入 /packs/"] --> Scan["2. 执行 packs scan 扫描与校验"] Scan --> Check{解析 manifest.json} Check -->|成功| Deploy["3. 部署至 worlds// 的 BP/RP 目录"] Check -->|损坏 / 缺文件| Fail["移入 packs/_failed/"] Deploy --> Enable["4. 写入 world_behavior_packs.json 启用清单"] - Enable --> Done["5. 将原安装包规整归档至 packs/_done/"] + Enable --> Done["5. 将原安装包归档至 packs/_done/"] ``` ## 2. 常用管理指令 @@ -61,16 +61,16 @@ sfmc> packs list --kind bp sfmc> packs enable sfmc> packs disable -# 健康诊断:检测世界清单接线问题,并查看当前存档实验性开关状态 +# 健康诊断:检测世界清单引用问题,并查看当前存档实验性开关状态 sfmc> packs doctor # 仅查看当前存档的实验性开关开启状态 sfmc> packs doctor --experiments -# 一键修复接线问题并补齐依赖的 Beta APIs +# 一键修复引用问题并补齐依赖的 Beta APIs sfmc> packs doctor --fix -# 一键自愈接线并开启全部已知实验性功能 +# 一键修复清单引用并开启全部已知实验性功能 sfmc> packs doctor --fix --all-experiments # 按需开启指定实验性功能(支持逗号分隔,支持别名) @@ -79,52 +79,52 @@ sfmc> packs doctor --fix --experiments=upcoming,cameras,voxel,villager,drop3,edu # 打印世界附加包落盘绝对路径 sfmc> packs path -# 安全卸载(移出世界目录并放入 _trash 回收站,自动级联清理配对 RP) +# 安全卸载(移出世界目录并放入 _trash 回收站,自动清理配对 RP) sfmc> packs uninstall -# 彻底清除(直接永久物理删除,不保留在回收站) +# 彻底清除(直接永久删除,不保留在回收站) sfmc> packs uninstall --purge ``` -#### 存档实验性开关(Experiments)诊断与自愈 +#### 存档实验性开关(Experiments)诊断与配置 -许多现代基岩版附加包(尤其是使用自定义方块几何、实体相机、原生脚本或最新玩法的包)依赖世界存档 `level.dat` 中的实验性功能。`packs doctor` 提供了对以下当前版本已知实验性玩法的原生安全读写支持: +许多现代基岩版附加包(如使用自定义方块几何、实体相机、原生脚本或最新特性的包)依赖世界存档 `level.dat` 中的实验性功能。`packs doctor` 支持对以下已知实验性特性的安全读写: | 实验性功能 | NBT 路径 / 特性 | 常用别名 | 作用说明 | | :--------------------------- | :----------------------------------------- | :-------------------------- | :---------------------------------------------- | | **测试版 API (Beta APIs)** | `experiments.gametest` | `beta`, `gametest` | 供 `@minecraft/server` 等脚本模块调用测试期接口 | | **即将推出的创作者功能** | `experiments.upcoming_creator_features` | `upcoming` | 启用多方块 Trait 与扩展模型旋转 | | **创建者照相机的实验性功能** | `experiments.experimental_creator_cameras` | `cameras` | 启用自定义机位与 `/camera` 视口控制 | -| **实验性Voxel形状特征** | `experiments.voxel_shapes` | `voxel` | 启用非方块形状的面剔除(Face Culling)与碰撞 | +| **实验性 Voxel 形状特征** | `experiments.voxel_shapes` | `voxel` | 启用非方块形状的面剔除(Face Culling)与碰撞 | | **村民贸易再平衡** | `experiments.villager_trades_rebalance` | `villager`, `trades` | 启用按生物群系区分的图书管理员交易及矿车更新 | -| **2026年第3次更新** | `experiments.drop_3_2026` | `drop3`, `wilderness_bound` | 提前体验斑驳森林、坐垫、草床等新特性 | +| **2026 年第 3 次更新** | `experiments.drop_3_2026` | `drop3`, `wilderness_bound` | 体验新生物群系与新方块等特性 | | **Minecraft Education 功能** | `educationFeaturesEnabled` (根标签) | `edu`, `education` | 开启化学工作台、元素周期表与特殊教育方块 | -每次执行修改均会自动创建 `level.dat.bak` 灾备,校准 8 字节小端序文件头部,并通过临时文件原子替换,确保存档安全。 +修改时会自动创建 `level.dat.bak` 备份,校验 8 字节小端序文件头部,并通过临时文件原子替换,保证存档完整性。 :::warning 游戏生效机制 由于 Minecraft BDS 资源包清单在进程初始化时读取,**任何附加包的安装、启停或卸载操作,均需重启 BDS 服务端方可生效**。 ::: -## 3. 智能冲突与版本覆盖策略 +## 3. 冲突与版本覆盖策略 -当安装过程中发现目标世界中已存在相同 `UUID` 或相同文件夹名的附加包时,系统实施严密的安全保护机制: +当目标世界中已存在相同 `UUID` 或文件夹名的附加包时,处理规则如下: | 执行环境 | 冲突处理行为 | | :------------------------------- | :---------------------------------------------------------------------------------------------------------- | -| **交互模式(TTY 终端)** | 在控制台高亮对比新旧包的版本号与哈希,提示服主确认是否覆盖。 | -| **非交互模式(开服前自动扫描)** | 为防止生产事故,**默认跳过并打印警告日志**,绝不进行静默覆盖;如确认要无条件更新,可追加 `--force` 参数。 | -| **高版本自然升级** | 当检测到待装包的 UUID 与旧包完全一致且 `version` 明确更高时,安装引擎会自动平滑覆写原目录,保留配置连续性。 | +| **交互模式(TTY 终端)** | 在控制台对比新旧包的版本号与哈希,提示服主确认是否覆盖。 | +| **非交互模式(开服前自动扫描)** | **默认跳过并打印警告日志**,不会静默覆盖;如需强制覆盖,可追加 `--force` 参数。 | +| **版本升级** | 当检测到待装包的 UUID 与旧包一致且 `version` 更高时,安装引擎会自动覆盖原目录,保持配置连续性。 | -## 4. CurseForge 自动化更新与源绑定 +## 4. CurseForge 扩展包更新与源绑定 -SFMC 内置了面向基岩版附加包的 CurseForge 驱动。只要配置了 API Key,即可实现扩展包的云端更新检索与一键升级: +SFMC 支持连接 CurseForge 基岩版资源库。配置 API Key 后,可检索扩展包并检查升级: ```bash # 在 CurseForge 基岩版资源库中搜索项目 sfmc> packs search "Slash Blade" -# 将世界中已安装的附加包与 CurseForge 项目 slug 强绑定 +# 将世界中已安装的附加包与 CurseForge 项目 slug 绑定 sfmc> packs bind slash-blade-addon # 查看所有已绑定的远端更新源 @@ -133,18 +133,18 @@ sfmc> packs sources # 检查是否存在可用更新 sfmc> packs check -# 一键将所有可用更新下载并应用到世界 +# 一键下载并更新所有已绑定的附加包 sfmc> packs update --all ``` ### 关联配置文件 - **全局策略 (`configs/pack-update.json`)**:配置 CurseForge API Key、开服时是否静默检查以及名称匹配模糊阈值。 -- **本地映射 (`packs/pack-sources.json`)**:维护每个本地包与远端工程的绑定关系,可随时通过 `packs unbind ` 解除关联。 +- **本地映射 (`packs/pack-sources.json`)**:维护每个本地包与远端工程的绑定关系,可通过 `packs unbind ` 解除关联。 ### 强制客户端材质刷新(RP Bump) -在修改或升级资源包(RP)后,基岩版客户端经常因为本地贴图缓存而无法即时看到最新材质。 +在修改或升级资源包(RP)后,基岩版客户端有时会因本地贴图缓存而未即时加载最新材质。 执行以下指令,可自动将指定资源包的 `patch` 版本号自增 1: @@ -152,4 +152,4 @@ sfmc> packs update --all sfmc> packs bump ``` -客户端在下次连接服务器时,检测到 RP 版本号变更,将自动重新下载并应用全新材质缓存。 +客户端在下次连接服务器时,检测到 RP 版本号变更,将自动重新下载并应用新材质。 diff --git a/docs/zh/guide/backup-upgrade.mdx b/docs/zh/guide/backup-upgrade.mdx index 40575b2a..6cb79abe 100644 --- a/docs/zh/guide/backup-upgrade.mdx +++ b/docs/zh/guide/backup-upgrade.mdx @@ -90,7 +90,7 @@ sfmc> /start -all ``` -## 2. 全组件平滑升级策略 +## 2. 组件升级指引 SFMC 平台的各层组件相互解耦,可按需分别升级: @@ -126,7 +126,7 @@ pnpm install && pnpm run build ### ② 升级 Bedrock Dedicated Server -SFMC 内置了官方版本探测与自动升级工具,可精准解析 Mojang 官方分发源: +SFMC 内置了版本检测与升级命令,支持直接从 Mojang 官方源下载: ```bash # 检查当前运行版本与远端最新版本 @@ -136,14 +136,14 @@ sfmc> update --check-only sfmc> update ``` -#### 白名单防覆盖与配置本地化(Preserve & Localize) -升级过程中,系统会严格依据 `configs/bds_updater.json` 中的 `preserve` 清单,在解压新版覆盖前**自动封存**以下文件,并在覆盖完成后**原样复原**: +#### 文件保留与配置处理 +升级过程中,系统会依据 `configs/bds_updater.json` 中的 `preserve` 清单,在覆盖前保留以下文件/目录,覆盖完成后自动恢复: - `server.properties`(服务端配置) - `whitelist.json` / `permissions.json`(原生白名单) - `worlds/`(所有世界存档与已安装的行为包) -:::tip server.properties 智能增量本地化 -在安全复原 `server.properties` 后,更新引擎会自动对其执行结构化本地化辅助:在**百分之百保留服主既有自定义配置值**的前提下,自动将配置注释升级为结构清晰的简体中文说明,并校验补全 EULA 遥测项(`emit-server-telemetry=true`)。 +:::tip server.properties 本地化注释 +恢复 `server.properties` 后,平台会自动将常用配置注释翻译为简体中文说明,并在保留服主既有配置值的前提下补全 EULA 遥测项(`emit-server-telemetry=true`)。 ::: 涉及数据库 Schema 迁移时,先在隔离副本上演练,并保留迁移前冷备份。不要假定新版本写入的数据可由旧版直接读取。 @@ -162,17 +162,17 @@ sfmc> mod install economy sfmc> mod reload --build-only ``` -## 3. 紧急灾难恢复与回滚方案(Rollback) +## 3. 灾难恢复与回滚方案(Rollback) -如果在升级后发现版本兼容性问题或意外异常,可按以下矩阵实施应急回滚: +如果在升级后发现版本兼容性问题或异常,可按以下方式实施应急回滚: | 故障场景 | 回滚与救援步骤 | | :--- | :--- | | **模块升级后出现逻辑 Bug** | 停机后从冷备份恢复模块目录、锁文件和 catalog,或运行 `sfmc mod install --from npm:@sfmc-bds/module-@<旧版本号>` 安装精确旧版;重新构建部署,再启动 BDS 验收。仅 `mod disable` 也需要重新部署并重启或重载 BDS 才能生效。 | -| **BDS 核心升级后无法启动** | 若配置了 `configs/bds_updater.json` 的 `backup_dir`,升级前会自动生成全量镜像;将备份目录中的旧版本二进制解压回覆盖原路径即可。 | +| **BDS 核心升级后无法启动** | 若配置了 `configs/bds_updater.json` 的 `backup_dir`,升级前会自动生成全量镜像;将备份目录中的旧版本二进制解压覆盖回原路径即可。 | | **误改配置导致启动崩溃** | 停止相关服务,从对应版本的冷备份恢复配置,然后启动并检查新日志。 | | **数据库损坏或脏数据** | 停止全部写入,保留故障现场副本,再从经校验的冷备份恢复数据库及对应 WAL 文件;启动前检查 SQLite 完整性。不要直接删除当前数据库。 | -:::tip 审计追踪 -所有操作细节与异常堆栈均可在 `/.sfmc/logs/` 下查阅独立日志(`db.log`、`qq.log`、`bds.log`)。在报告问题前,请附带该目录下的相关诊断信息。 +:::tip 诊断日志 +服务日志与异常堆栈保存在 `/.sfmc/logs/` 目录下(`db.log`、`qq.log`、`bds.log`)。在排查或反馈问题时,请附带该目录下的相关诊断日志。 ::: diff --git a/docs/zh/guide/commands.md b/docs/zh/guide/commands.md index fb29eea1..5569a731 100644 --- a/docs/zh/guide/commands.md +++ b/docs/zh/guide/commands.md @@ -1,66 +1,66 @@ -# 控制台命令全景速查 +# 控制台命令速查 -在 `sfmc` 交互式终端(REPL)或系统 Shell 环境下,可使用下列指令进行服务管控、模块编排与扩展包管理。 +在 `sfmc` 交互控制台(REPL)或系统终端中,可使用下列命令进行服务管控、模块管理与扩展包管理。 在交互终端中,输入 `help` 可列出当前上下文支持的全部命令;输入 `/` 可激活交互式命令补全菜单。 :::tip 命令别名约定 -模块管理:`mod` ≡ `module`(顶层短命令 `install` / `uninstall` / `search` / `list` 等同于 `mod `) -附加包管理:`packs` ≡ `addon` -详细执行机制请分别查阅:[服务管理](./services.mdx)、[模块管理](./modules.mdx)、[附加包管理](./addons.md)。 +- 模块管理:`mod` ≡ `module`(顶层短命令 `install` / `uninstall` / `search` / `list` 等同于 `mod `) +- 附加包管理:`packs` ≡ `addon` +- 详细执行机制请分别查阅:[服务管理](./services.mdx)、[模块管理](./modules.mdx)、[附加包管理](./addons.md)。 ::: -## 1. 伴生服务编排(Service Management) +## 1. 服务管理(Service Management) | 命令与常用参数 | 典型示例 | 功能与行为说明 | | :----------------------- | :-------------------------------------------- | :---------------------------------------------------------------------------------------------------- | | `status` | `sfmc> status` | 查看所有伴生服务(db / qq / bds)的运行状态、PID 与内存占用。 | -| `start ` | `sfmc> /start -all`
`sfmc> start db` | 启动全部服务或指定子服务(可选:`db`、`qq`、`llbot`、`bds`、`-all`)。自动执行依赖自检与装载闸门。 | -| `stop ` | `sfmc> stop bds` | 安全平滑停止指定服务或终止所有进程(`-all`)。 | -| `restart ` | `sfmc> restart qq` | 重新启动指定服务。常用于修改了 `qq_config.json` 或 `db_config.json` 后热重启。 | -| `logs [-n N] [-f]` | `sfmc> logs bds -f`
`sfmc> logs db -n 50` | 实时查看或追踪指定服务的运行日志。在交互终端中亦可随时按下快捷键 `Ctrl + L` 或 `Tab` 快速切换日志源。 | -| `send ` | `sfmc> send bds op Alex` | 向指定服务的子进程标准输入(stdin)注入字符指令(仅在交互式 REPL 中有效)。 | -| `init` | `sfmc> init` | 重新唤起首次开服交互式初始化向导(需在标准 TTY 终端中运行)。 | +| `start ` | `sfmc> /start -all`
`sfmc> start db` | 启动全部服务或指定子服务(可选:`db`、`qq`、`llbot`、`bds`、`-all`)。自动执行前置依赖检查与装载闸门。 | +| `stop ` | `sfmc> stop bds` | 安全停止指定服务或终止所有进程(`-all`)。 | +| `restart ` | `sfmc> restart qq` | 重新启动指定服务。常用于修改 `qq_config.json` 或 `db_config.json` 后应用配置。 | +| `logs [-n N] [-f]` | `sfmc> logs bds -f`
`sfmc> logs db -n 50` | 实时查看或追踪指定服务的运行日志。在交互终端中亦可随时按下快捷键 `Ctrl + L` 切换日志源。 | +| `send ` | `sfmc> send bds op Alex` | 向指定服务的子进程标准输入(stdin)发送指令(仅在交互式 REPL 中有效)。 | +| `init` | `sfmc> init` | 唤起首次开服交互式初始化向导(需在标准 TTY 终端中运行)。 | | `update [--check-only]` | `sfmc> update` | 检查并升级 Bedrock Dedicated Server 官方服务端。使用 `--check-only` 仅查询版本不下载。 | -## 2. 功能模块管控(Module Management) +## 2. 模块管理(Module Management) | 命令与常用参数 | 典型示例 | 功能与行为说明 | | :-------------------------------------------- | :--------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------- | -| `mod search [keyword]` | `sfmc> mod search economy` | 在官方 `sfmc-modules` 索引库中模糊检索可用功能模块。 | +| `mod search [keyword]` | `sfmc> mod search economy` | 在官方 `sfmc-modules` 索引库中检索可用功能模块。 | | `mod info ` | `sfmc> mod info afk` | 查看指定模块的作者、版本号、描述与依赖项清单。 | | `mod install [--from ] [--link]` | `sfmc> mod install afk`
`sfmc> mod install my-mod --from dir:../my-mod --link` | 下载并安装模块到 `modules/packages/`。支持从本地目录挂接(`--link` 软链调试)。 | | `mod uninstall ` | `sfmc> mod uninstall afk` | 卸载指定模块并清理其源码文件。 | | `mod list` | `sfmc> mod list` | 列出本地已安装模块、启停状态、文件数、体积、源码指纹与官方认证标识(`●` 官方启用 / `○` 官方禁用 / `?` 未知发布源 / `○` 契约缺失)。 | -| `mod enable ` | `sfmc> mod enable afk` | 激活指定模块。写入 `module-lock.json`,若 db-server 在线则同步热推内存态。 | -| `mod disable ` | `sfmc> mod disable afk` | 禁用指定模块。禁用后下次组装行为包时将自动排除该模块。 | +| `mod enable ` | `sfmc> mod enable afk` | 启用指定模块。写入 `module-lock.json`,若 db-server 在线则同步热更新内存状态。 | +| `mod disable ` | `sfmc> mod disable afk` | 禁用指定模块。禁用后下次组装行为包时将排除该模块。 | | `mod verify` | `sfmc> mod verify` | 校验本地所有模块的 `manifest.json` 契约合规性与依赖完整度。 | -| `mod build` | `sfmc> mod build` | 仅执行动态组装与编译,将启用的模块编译至 `packs/_build/` 暂存区。 | -| `mod reload [--build-only]` | `sfmc> mod reload` | 编译、组装并部署至当前世界目录,同时向 BDS 发起重载请求(日常联调核心命令)。 | +| `mod build` | `sfmc> mod build` | 执行模块组装与编译,将启用的模块编译至 `packs/_build/` 暂存区。 | +| `mod reload [--build-only]` | `sfmc> mod reload` | 编译、组装并部署至当前世界目录,同时向 BDS 发起重载请求。 | :::info 模块开发者注意 -自新版起,模块作者向的本地单测、文件监视(Watch)与发布已统一收敛至 VS Code / Cursor 专属扩展 **「SFMC Module」**;CLI 不再提供旧版 `mod test`、`mod watch` 与 `mod publish` 命令。详见 [模块开发指南](../dev/module-author.mdx)。 +模块的本地测试、文件监视(Watch)与发布已收敛至 VS Code / Cursor 扩展 **「SFMC Module」**;CLI 不再提供旧版 `mod test`、`mod watch` 与 `mod publish` 命令。详见 [模块开发指南](../dev/module-author.mdx)。 ::: -## 3. 第三方附加包(Add-ons & Packs) +## 3. 附加包管理(Add-ons & Packs) -| 命令与常用参数 | 典型示例 | 功能与行为说明 | -| :---------------------------------------- | :--------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------- | -| `packs scan [--force] [--dry-run]` | `sfmc> packs scan` | 扫描 `/packs/` 收件箱中的待装包,自动解析并安装入世界。 | -| `packs list [--kind bp\|rp\|all]` | `sfmc> packs list --kind bp` | 列出当前游戏世界中已激活的所有第三方行为包(BP)与资源包(RP)。 | -| `packs install [--force]` | `sfmc> packs install ./cool.mcpack` | 直接安装指定物理路径下的附加包或处理收件箱归档。 | -| `packs enable ` | `sfmc> packs enable my-addon` | 在当前世界中启用指定附加包(支持使用 UUID 或文件夹名)。 | -| `packs disable ` | `sfmc> packs disable my-addon` | 在当前世界中停用指定附加包。 | -| `packs uninstall [--purge]` | `sfmc> packs uninstall my-addon` | 将附加包移出世界目录(默认移至 `packs/_trash/` 回收站;`--purge` 永久销毁)。 | -| `packs search ` | `sfmc> packs search "Vehicles"` | 在 CurseForge 基岩版资源库中远程搜索附加包项目。 | -| `packs bind ` | `sfmc> packs bind my-addon vehicle-pack` | 将已安装的本地附加包与 CurseForge 项目 slug 建立版本更新追踪绑定。 | -| `packs unbind ` | `sfmc> packs unbind my-addon` | 解除本地附加包与远端更新源的绑定。 | -| `packs sources` | `sfmc> packs sources` | 查看所有已登记的更新源映射表。 | -| `packs check` | `sfmc> packs check` | 检查所有已绑定的附加包是否有远端新版本发布。 | -| `packs update [--all]` | `sfmc> packs update --all` | 下载并升级所有已绑定的第三方附加包到最新版本。 | -| `packs bump ` | `sfmc> packs bump my-addon-rp` | 仅针对资源包(RP):将其 patch 版本号 +1,强制客户端拉取最新贴图缓存。 | -| `packs doctor` | `sfmc> packs doctor [--fix] [--all-experiments] [--experiments]` | 扫描排查世界清单接线损坏,诊断并安全配置 `level.dat` 实验性玩法开关(Beta APIs、创作者功能、照相机、Voxel、村民再平衡、2026第3次更新、教育版等)。 | -| `packs path` | `sfmc> packs path` | 打印当前世界行为包与资源包的落盘文件系统路径。 | +| 命令与常用参数 | 典型示例 | 功能与行为说明 | +| :---------------------------------------- | :--------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------- | +| `packs scan [--force] [--dry-run]` | `sfmc> packs scan` | 扫描 `/packs/` 收件箱中的待装包,自动解析并安装入世界。 | +| `packs list [--kind bp\|rp\|all]` | `sfmc> packs list --kind bp` | 列出当前游戏世界中已激活的所有第三方行为包(BP)与资源包(RP)。 | +| `packs install [--force]` | `sfmc> packs install ./cool.mcpack` | 直接安装指定路径下的附加包或处理收件箱归档。 | +| `packs enable ` | `sfmc> packs enable my-addon` | 在当前世界中启用指定附加包(支持使用 UUID 或文件夹名)。 | +| `packs disable ` | `sfmc> packs disable my-addon` | 在当前世界中停用指定附加包。 | +| `packs uninstall [--purge]` | `sfmc> packs uninstall my-addon` | 将附加包移出世界目录(默认移至 `packs/_trash/` 回收站;`--purge` 永久删除)。 | +| `packs search ` | `sfmc> packs search "Vehicles"` | 在 CurseForge 基岩版资源库中搜索附加包项目。 | +| `packs bind ` | `sfmc> packs bind my-addon vehicle-pack` | 将已安装的本地附加包与 CurseForge 项目 slug 绑定,用于版本更新。 | +| `packs unbind ` | `sfmc> packs unbind my-addon` | 解除本地附加包与远端更新源的绑定。 | +| `packs sources` | `sfmc> packs sources` | 查看所有已登记的更新源映射表。 | +| `packs check` | `sfmc> packs check` | 检查所有已绑定的附加包是否有远端新版本发布。 | +| `packs update [--all]` | `sfmc> packs update --all` | 下载并升级所有已绑定的第三方附加包到最新版本。 | +| `packs bump ` | `sfmc> packs bump my-addon-rp` | 针对资源包(RP):将其 patch 版本号 +1,使客户端重新拉取材质缓存。 | +| `packs doctor` | `sfmc> packs doctor [--fix] [--all-experiments] [--experiments]` | 扫描排查世界清单损坏,诊断并安全配置 `level.dat` 实验性玩法开关(Beta APIs、创作者功能、照相机、Voxel、村民再平衡、2026第3次更新、教育版等)。 | +| `packs path` | `sfmc> packs path` | 打印当前世界行为包与资源包的文件系统路径。 | ## 4. 全局通用与诊断命令(General & Diagnostics) @@ -68,6 +68,6 @@ | :------------------- | :----------------------------------------------------------- | | `help [command]` | 打印全局帮助手册或指定命令的参数详解。 | | `version` | 打印当前 SFMC CLI、Node.js 运行时及 SDK 的构建版本信息。 | -| `locale [lang]` | 切换或查看交互式终端界面语言(如 `zh` 或 `en`)。 | -| `quit` / `exit` | 平稳退出当前的交互式 REPL 控制台(后台服务将继续保持运行)。 | -| `debug ` | 开发者专用底层诊断工具,用于查看内部状态树与事件流。 | +| `locale [lang]` | 切换或查看交互控制台界面语言(如 `zh` 或 `en`)。 | +| `quit` / `exit` | 退出交互控制台(后台服务保持运行)。 | +| `debug ` | 底层诊断工具,用于查看内部状态与事件流。 | diff --git a/docs/zh/guide/config.md b/docs/zh/guide/config.md index 6551b929..f3e6d456 100644 --- a/docs/zh/guide/config.md +++ b/docs/zh/guide/config.md @@ -4,9 +4,9 @@ SFMC 采用**约定优于配置(Convention over Configuration)**的设计理 ## 1. 核心机制与设计原则 -- **冷启动自愈(Zero-Config Bootstrap)**:首次启动任何服务(`sfmc`、`db-server`、`qq-bridge`)时,系统会自动探测并生成缺失的标准配置文件,填入安全的默认参数。 -- **静态强校验(JSON Schema)**:每个自动生成的 JSON 文件首行均预注入 `$schema` 契约指针。在 VS Code 或 Cursor 中编辑时,可享受即时的字段悬停解释、代码补全与语法检查。 -- **无感环境覆盖(Environment Overrides)**:生产环境(如 Docker、CI/CD 或云服务器守护进程)下,可通过环境变量覆盖敏感凭证与关键路径,无需侵入文件。 +- **自动生成初始配置**:首次启动任何服务(`sfmc`、`db-server`、`qq-bridge`)时,系统会自动探测并生成缺失的标准配置文件,填入安全的默认参数。 +- **静态强校验(JSON Schema)**:自动生成的 JSON 文件首行均预注入 `$schema` 契约指针。在编辑器中可提供字段悬停解释、代码补全与语法检查。 +- **环境变量覆盖(Environment Overrides)**:生产环境(如 Docker、CI/CD 或云服务器守护进程)下,可通过环境变量覆盖敏感凭证与关键路径。 ```text / @@ -94,7 +94,7 @@ SFMC 采用**约定优于配置(Convention over Configuration)**的设计理 - **群标识 (`official.group_openid`)**:官方机器人对应的群唯一 OpenID(注意:**并非传统群号**)。首次拉入机器人后在群内 `@机器人`,可在 `qq-bridge` 日志中查看自动捕获并打印的 OpenID。 - **事件推群开关 (`qq_events`)**:细粒度控制进服、退服、玩家阵亡、服务器起停等系统广播是否转发至 QQ 群。 -> 完整机器人联调步骤与权限申请指引,请参阅 [QQ 互通配置手册](./qq-bridge.md)。 +> 完整机器人联调步骤与权限申请指引,请参阅 [QQ 互通配置手册](./qq-bridge.mdx)。 --- @@ -205,35 +205,35 @@ BDS 控制台或高频实体脚本常常会打印过量无效信息。通过 `lo --- -### `server.properties`:BDS 原生配置与智能本地化辅助 +### `server.properties`:BDS 原生配置与本地化辅助 位于 `/server.properties`,是 Minecraft 基岩版官方独立服务端(BDS)的核心配置文件,控制网络端口、视距、正版验证、默认游戏模式与世界难度等关键底层参数。 -为了降低服主理解门槛与排错成本,SFMC 平台内置了 **`server.properties` 智能本地化与合规辅助引擎**(由 `@sfmc-bds/bds-tools` 提供驱动): +SFMC 平台提供了 **`server.properties` 本地化与配置辅助功能**(由 `@sfmc-bds/bds-tools` 提供): 1. **自动本地化注释(多语言翻译)**: - **生效时机**:执行 `sfmc update`(安装或版本更新解压后)、或日常通过 `sfmc start` 启动 BDS 进程前自动执行。 - - **零破坏原则**:严格基于属性感知 AST 解析,**绝对不修改任何配置 Key**,**百分之百保留服主自定义修改的 Value**(如端口、地图名、最大人数等),未来未知新增项与自定义注释原样完整保留。 - - **结构化中文说明**:涵盖官方约 40 个常用配置项,提供清晰的功能概述、合法取值枚举与避坑建议(如 Xbox 成就影响、SAPI 脚本报错控制台输出等)。 - - **语言环境联动**:根据 `configs/runtime.json#locale`、`--lang` 参数或系统语言自动判定;非中文环境(如 `en`)保持原版英文不变。 + - **保留自定义配置**:基于属性解析,不修改配置键名,保留服主自定义修改的配置值(如端口、地图名、最大人数等),未知新增项与自定义注释原样保留。 + - **结构化中文说明**:涵盖官方约 40 个常用配置项,提供功能概述、合法取值枚举与注意事项。 + - **语言环境联动**:根据 `configs/runtime.json#locale`、`--lang` 参数或系统语言自动判定;非中文环境(如 `en`)保持原版英文。 2. **EULA 遥测合规补丁(`emit-server-telemetry=true`)**: - - 根据 Mojang EULA 协议约定,当服主在初始化向导同意 EULA 后,平台会在启动和升级收尾阶段自动确保 `emit-server-telemetry=true` 写入并附带中文说明,避免控制台反复出现协议未确认警告。 + - 根据 Mojang EULA 协议约定,当服主在初始化向导同意 EULA 后,平台会在启动和升级阶段自动确保 `emit-server-telemetry=true` 写入并附带中文说明。 3. **幂等性与安全编码**: - - 保证幂等写入(多次启动或更新不会导致注释重复追加或排版混乱),强制采用无 BOM 的标准 UTF-8 编码,彻底杜绝乱码与解析异常。 + - 保证幂等写入(多次启动或更新不会导致注释重复追加),采用无 BOM 的标准 UTF-8 编码。 ## 3. 模块状态与私有配置 ### 模块运行时状态文件 -位于 `/modules/` 目录下,记录当前工作区的模块拓扑状态: +位于 `/modules/` 目录下,记录当前工作区的模块状态: | 文件 | 角色与职责 | 维护者 | | :------------------------- | :----------------------------------------------------------------------------------------------- | :-------------------------------------------------------------- | | `modules/catalog.json` | **本地模块清单**。记录所有已解压安装模块的名称、版本、依赖关系与代码入口路径。作为只读静态索引。 | `sfmc mod install` / `uninstall` 自动维护 | -| `modules/module-lock.json` | **启停状态锁**。记录每个模块的显式激活状态(`"enabled": true | false`)。**这是 BDS 装载闸门判断是否打包该模块的唯一真理源**。 | `sfmc mod enable` / `disable` 或 Admin GUI 维护 | +| `modules/module-lock.json` | **启停状态锁**。记录每个模块的显式激活状态(`"enabled": true` / `false`)。这是构建与装载阶段判断是否打包该模块的依据。 | `sfmc mod enable` / `disable` 维护 | -:::important 锁文件原则 -切勿手动破坏 `module-lock.json` 的 JSON 格式。启停模块请统一使用命令 `sfmc mod enable ` 或 `sfmc mod disable `,确保状态同步与行为包热重载平滑执行。 +:::important 锁文件说明 +不要随意手动修改 `module-lock.json` 的 JSON 格式。启停模块请统一使用命令 `sfmc mod enable ` 或 `sfmc mod disable `,以确保状态同步与行为包热重载正常进行。 ::: ### 模块私有配置(`.json`) diff --git a/docs/zh/guide/index.mdx b/docs/zh/guide/index.mdx index 4a9fefcf..d926b0ad 100644 --- a/docs/zh/guide/index.mdx +++ b/docs/zh/guide/index.mdx @@ -1,65 +1,48 @@ # 快速上手 -欢迎使用 **ScriptsForMinecraftServer (SFMC)**。SFMC 是专为官方原版 Bedrock Dedicated Server (BDS) 打造的现代化 SAPI 行为包生态与 Node.js 伴生服务架构。 - -请根据您的使用角色选择下方的上手路径: - -
-
-
-
🎮
-

我是服主 / 运维人员

-

- 快速安装 CLI 工具、初始化 BDS 服务端、安装官方推荐模块并配置 QQ 互联机器人。 -

-
- - 开始部署运维指南 → - -
- -
-
-
💻
-

我是插件 / 模块开发者

-

- 使用脚手架快速创建独立模块、本地热重载调试、编写持久化数据库及发布到模块生态。 -

-
- - 进入模块开发手册 → - -
+欢迎使用 **ScriptsForMinecraftServer (SFMC)**。SFMC 是为官方原版 Bedrock Dedicated Server (BDS) 打造的 SAPI 行为包生态与 Node.js 伴生服务架构。 + +你可以根据使用场景选择上手路径: + + ## 系统要求 -在开始之前,请确保您的运行环境满足以下要求: +在开始之前,请确保运行环境满足以下要求: | 项目 | 最低要求 | 推荐配置 | | :--- | :--- | :--- | -| **操作系统** | Windows 10 / 11, Linux (Ubuntu 20.04+, Debian 11+) | Linux (云服务器长期运行) / Windows (本地调试) | -| **Node.js** | `v22.13.0` 或更高版本(需内置 SQLite 支持) | 最新 LTS 版本(Node ≥22.13) | +| **操作系统** | Windows 10 / 11, Linux (Ubuntu 20.04+, Debian 11+) | Linux (云服务器运行) / Windows (本地调试) | +| **Node.js** | **`≥ 22.13.0`**(需支持内置 `node:sqlite`) | 最新 LTS 版本 | | **包管理器** | `pnpm` | `pnpm` | -| **磁盘空间** | ≥ 2 GB(用于存放 BDS 核心、行为包、备份与数据存档) | SSD 固态硬盘 | +| **磁盘空间** | ≥ 2 GB(用于存放 BDS 核心、行为包产物与备份) | SSD 固态硬盘 | ## 快速安装与初始化 -SFMC 提供了全局编排 CLI 工具,可实现从 BDS 自动下载配置到模块启停的全流程管理。 +通过 CLI 工具 `sfmc` 可以一键完成 BDS 初始化、服务守护与模块管理。 -### 步骤 1:全局安装 SFMC CLI +### 1. 全局安装 CLI ```bash pnpm add -g @sfmc-bds/sfmc@0.2.2 ``` -:::tip 提示 -pnpm v11 起默认要求新版本发布满 24 小时才参与无版本号解析。发布当天安装请指定上面的精确版本;可先用 `pnpm view @sfmc-bds/sfmc version` 查询当前 `latest`。只有试用预发布版本时才使用 `@beta`。如需在特定文件夹持久化数据,可通过配置 `SFMC_ROOT` 环境变量指定自定义工作目录。 +:::tip 版本与目录说明 +pnpm v11 起对新发布的包存在版本解析延迟,初次安装建议显式指定版本;可通过 `pnpm view @sfmc-bds/sfmc version` 查看最新版本。数据默认存储在当前运行目录,也可通过环境变量 `SFMC_ROOT` 指定工作目录。 ::: -### 步骤 2:创建服务器工作区并启动向导 +### 2. 创建服务器目录并启动向导 -创建一个专用的服务器目录,并直接运行 `sfmc`: +创建服务器专用目录并执行 `sfmc`: ```bash mkdir my-minecraft-server @@ -67,68 +50,65 @@ cd my-minecraft-server sfmc ``` -首次运行会自动启动交互式初始化向导,完成以下准备工作: -1. 下载并解压适配当前系统的官方原版 Bedrock Dedicated Server。 -2. 自动生成标准目录结构并注册默认配置文件与 JSON Schema。 -3. 初始化轻量 SQLite 数据持久化守护服务(`db-server`)。 +首次运行会自动启动初始化向导: +1. 下载并解压当前操作系统适配的官方原版 BDS。 +2. 自动生成配置文件结构并绑定 JSON Schema。 +3. 初始化本地 SQLite 数据守护服务(`db-server`)。 -初始化完成后的目录结构如下: +初始化完成后的目录结构: ```text my-minecraft-server/ ├── BedrockDedicatedServer/ # BDS 核心服务端程序 -├── configs/ # 平台与各功能配置文件(db_config, qq_config 等) -├── data/ # SQLite 数据库与运行时状态 -├── packs/ # 自动组装输出的行为包产物目录 -└── modules/ # 已下载与启用的功能模块包 +├── configs/ # 服务与功能配置文件(db_config, qq_config 等) +├── data/ # SQLite 数据库与运行时数据 +├── packs/ # 自动构建输出的行为包产物目录 +└── modules/ # 已下载与启用的功能模块 ``` -## 常用运维指令 +## 常用服务管理命令 -SFMC 内置了现代化的交互式终端面板(REPL),也支持在宿主 Shell 中直接调用子命令: +在 `sfmc` 交互控制台(REPL)中可直接执行管理命令: -### 1. 服务控制 - -在 `sfmc` 终端内输入 `/` 即可唤起交互式命令菜单: +### 1. 启停服务 ```bash # 启动全部服务(db-server + qq-bridge + BDS) sfmc> /start -all -# 仅启动 BDS 或查看运行状态 +# 仅启动 BDS 或查看当前运行状态 sfmc> /start bds sfmc> /status -# 平滑停止所有服务 +# 安全停止所有服务 sfmc> /stop -all ``` -:::info 快捷键操作 -在 REPL 交互界面中,直接按下键盘上的 `TAB` 键可以在不同的日志源(BDS 主控、数据库服务、QQ 桥接)之间实时无缝切换。 +:::info 控制台快捷键 +在控制台中按下 `TAB` 键,可在 BDS 控制台、数据库中枢和 QQ 桥接的日志流之间切换。 ::: -### 2. 模块发现与安装 +### 2. 模块管理 -SFMC 拥有开箱即用的模块分发生态,可一键安装常用功能模块(如挂机检测、经济系统、传送点管理等): +可以通过模块生态安装常用功能(如挂机检测、经济系统、传送点管理等): ```bash -# 在官方模块索引中搜索模块 +# 搜索可用模块 sfmc> mod search # 安装指定模块 sfmc> mod install afk -# 启用并加载模块 +# 启用模块 sfmc> mod enable afk -# 热重载行为包代码(无需重启 BDS) +# 重新组装并向游戏请求重载 sfmc> mod reload ``` -## 下一步 - -- 📖 **深入模块管理**:阅读 [模块与行为包管理指南](./modules.mdx) 了解模块更新与依赖关系。 -- 🤖 **接入 QQ 互通**:阅读 [QQ 桥接配置手册](./qq-bridge.md) 配置官方机器人或 LLBot。 -- 🔍 **遇到问题?**:使用 [交互式排障向导](./troubleshooting.mdx) 快速解决端口冲突与启动错误。 - +## 相关文档 +- [模块与行为包管理指南](./modules.mdx) — 模块安装、启停与行为包装配说明 +- [QQ 桥接配置手册](./qq-bridge.mdx) — 配置官方机器人与 LLBot 消息互通 +- [命令列表](./commands.md) — 完整 CLI 与 REPL 命令速查 +- [故障排查向导](./troubleshooting.mdx) — 常见端口冲突与启动错误排查 diff --git a/docs/zh/guide/modules.mdx b/docs/zh/guide/modules.mdx index 01ff5173..c0e83468 100644 --- a/docs/zh/guide/modules.mdx +++ b/docs/zh/guide/modules.mdx @@ -2,58 +2,57 @@ import { Steps } from "@rspress/core/theme"; # 模块与行为包管理 -在 SFMC 架构中,所有游戏业务逻辑(如经济系统、领地保护、挂机检测、传送菜单等)均以独立的**功能模块(Module)**形式存在。 +在 SFMC 中,服务器的功能与玩法(如经济、领地、挂机检测、菜单等)均以独立的**功能模块(Module)**形式提供。 -本页面向服主与运维人员,详细阐释如何在服务器中**搜索、安装、启停模块**,以及系统如何将多个独立模块动态编排组装为原生 BDS 行为包。 +本文介绍如何在服务器中**搜索、安装、启停模块**,以及系统如何将多个独立模块自动装配为原生 BDS 行为包。 -:::tip 开发者指引 -如果您希望自行开发、测试并发布新的功能模块,请直接查阅 [模块开发指南](../dev/module-author.mdx)。 +:::tip 模块开发者指引 +如果希望自行开发与发布模块,请查阅 [模块开发指南](../dev/module-author.mdx)。 ::: -## 1. 模块生态体系与架构 +## 1. 模块体系结构 -SFMC 模块具备高度工程化的解耦设计: +SFMC 模块采用模块化设计与集中打包: -- **代码分发面(npm)**:模块源码以标准 npm 包形式发布(如官方包 `@sfmc-bds/module-economy` 或社区包 `@author/sfmc-module-myfeature`),杜绝传统“靠群文件传 zip”的版本混乱问题。 -- **发现索引(Index)**:[`sfmc-modules`](https://github.com/Tanya7z/sfmc-modules) 官方仓库仅作为轻量索引目录,用于驱动 CLI 的搜索与元数据拉取。 -- **本地组装面(Assemble)**:服主勾选启用的所有模块,会在开服前由打包引擎自动编译聚合为单一合法的原生行为包 `sfmc-modules`,直接注入当前世界。 +- **npm 分发**:模块以标准 npm 包发布(官方模块如 `@sfmc-bds/module-economy`,社区模块如 `@author/sfmc-module-myfeature`),具备明确的语义化版本。 +- **社区索引**:[`sfmc-modules`](https://github.com/Tanya7z/sfmc-modules) 仓库维护可用模块目录,供 CLI 检索与下载。 +- **动态组装**:已启用的所有模块会在开服前由打包工具自动编译并整合为单个行为包 `sfmc-modules`,直接注入当前世界。 ```mermaid flowchart LR - Index[官方模块索引 index.json] -->|检索元数据| CLI[sfmc mod search] - npm[npm 制品仓库] -->|下载安装包| Local[modules/packages/] - Local --> Catalog[catalog.json 清单] - Lock[module-lock.json 启停锁] --> Assembler[动态装配引擎 esbuild] + Index[模块索引 index.json] -->|检索元数据| CLI[sfmc mod search] + npm[npm Registry] -->|下载模块包| Local[modules/packages/] + Local --> Catalog[catalog.json 镜像] + Lock[module-lock.json 启停状态] --> Assembler[构建工具 esbuild] Catalog --> Assembler - Assembler --> BP[BDS 世界行为包 sfmc-modules] + Assembler --> BP[世界行为包 sfmc-modules] ``` ## 2. 常用管理命令 -在 `sfmc` 控制台或宿主 Shell 中,可直接通过 `mod`(或全称 `module`)子命令进行全生命周期管理: +在 `sfmc` 控制台或终端中,可通过 `mod` 子命令进行模块管理: ### 检索与安装 ```bash -# 检索官方模块库中的可用模块 +# 搜索可用模块 sfmc> mod search -# 查看已下载模块的详细信息 +# 查看模块详情 sfmc> mod info economy -# 一键下载并安装指定模块(支持同时安装多个) +# 安装指定模块(支持批量安装) sfmc> mod install afk economy teleport ``` 安装源说明(`--from`): - -- 默认为 npm 源:`@sfmc-bds/module-` 或 `@scope/sfmc-module-`。 -- 支持本地目录调试(挂接作者仓):`sfmc mod install --from dir:<绝对路径> --link`。 +- 默认从 npm 源安装:`@sfmc-bds/module-` 或 `@scope/sfmc-module-`。 +- 支持本地目录软链接(便于本地调试):`sfmc mod install --from dir:<绝对路径> --link`。 ### 启停与卸载 ```bash -# 列出本地所有模块及其当前启停状态 +# 列出本地所有模块及其当前状态 sfmc> mod list # 启用指定模块 @@ -62,11 +61,11 @@ sfmc> mod enable afk # 禁用指定模块 sfmc> mod disable afk -# 卸载模块并清理本地源码 +# 卸载模块 sfmc> mod uninstall afk ``` -#### `mod list` 输出与状态标识 +#### `mod list` 输出说明 执行 `mod list` 会扫描 `/modules/packages/` 下的所有模块,输出格式示例如下: @@ -82,99 +81,89 @@ sfmc> mod uninstall afk tip: mod enable|disable ``` -**前置状态符号含义**: +**状态符号说明**: -| 符号 | 颜色 | 状态含义 | 详细说明 | +| 符号 | 颜色 | 状态含义 | 说明 | | :--- | :--- | :--- | :--- | -| **`●`** | 绿色 | **官方已启用** | 属于官方中心发现索引(`sfmc-modules`)收录模块,且在 `module-lock.json` 中处于启用状态(`on`)。 | -| **`○`** | 灰色 | **官方已禁用** | 属于官方收录模块,但在 `module-lock.json` 中处于禁用状态(`off`)。 | -| **`?`** | 黄色 | **未知发布源** | 模块已安装在本地,但**未在官方中心发现索引库中收录**。CLI 启动时会打印黄色安全校验提醒(`modules installed from unknown publisher`),建议核实发布者来源。 | -| **`○`** | 黄色 | **契约异常** | 本地 `sapi/manifest.json` 文件缺失或损坏,无法读取模块元数据。 | - -**字段与列说明**: -- **`id`**:显示模块的物理安装目录名。当物理目录名与模块内部 `manifest.json` 中声明的逻辑 ID 不一致时,会自动以 `目录名(逻辑ID)` 形式标注(如 `qq-link(feature-qq-link)`)。执行 `mod enable` / `mod disable` 时支持使用任一标识。 -- **`state`**:当前在 `module-lock.json` 中的启停状态(`on` 表示参与行为包编译,`off` 表示排除)。 -- **`files` / `size`**:模块的有效业务源码文件总数与总体积(已自动忽略 `node_modules`、`.git`、`dist` 等构建/依赖目录)。 -- **`fingerprint`**:模块当前业务代码的 SHA-256 指纹截断,用于装载闸门快速比对世界行为包是否需要重新组装。 - -:::tip REPL 下的命令模式提示 -在交互式 REPL 中,当后台运行有服务(例如 BDS 或 DB Server)时,命令行提示符会转变为服务透传模式(如 `bds> `)。此时输入未带斜杠的文本会被直接当作控制台指令发送至该服务。执行平台命令请使用斜杠前缀(如 `/mod list`、`/status`);若当前无服务运行(`sfmc> `),则可直接输入 `mod list`。 -::: - -:::important 启停状态与锁文件机制 -`mod enable` 与 `mod disable` 会将状态持久化写入 `/modules/module-lock.json`。 -**离线安全**:即使 `db-server` 处于离线状态,启停修改依然能安全落盘;待下次启动数据库时会自动同步内存态。 -**游戏生效**:在 BDS 运行期间修改了模块启停状态,**必须执行 `mod reload` 或重启 BDS** 才能在游戏中生效。 +| **`●`** | 绿色 | **已启用** | 收录于官方索引,且在 `module-lock.json` 中处于启用状态(`on`)。 | +| **`○`** | 灰色 | **已禁用** | 收录于官方索引,但在 `module-lock.json` 中处于禁用状态(`off`)。 | +| **`?`** | 黄色 | **未知来源** | 模块已安装在本地,但未在官方索引库中收录。 | +| **`○`** | 黄色 | **配置损坏** | 本地 `sapi/manifest.json` 缺失或格式损坏。 | + +**字段含义**: +- **`id`**:模块安装目录名。当目录名与模块内部 `manifest.json` 的逻辑 ID 不一致时,显示为 `目录名(逻辑ID)`。执行 `mod enable` / `mod disable` 时支持使用任一名称。 +- **`state`**:当前启停状态(`on` 表示参与行为包编译,`off` 表示排除)。 +- **`files` / `size`**:模块源码文件数与总体积(已排除 `node_modules`、`.git` 等目录)。 +- **`fingerprint`**:模块当前代码的 SHA-256 指纹截断,用于判断是否需要重新打包。 + +:::important 启停状态与生效时机 +`mod enable` 与 `mod disable` 会将状态持久化写入 `/modules/module-lock.json`。 +在 BDS 运行期间修改了模块状态后,**必须执行 `mod reload` 或重启 BDS** 才能在游戏中生效。 ::: -## 3. 行为包动态装配机制 +## 3. 行为包装配机制 -传统开服方式中,安装多个 Script API 脚本包极易引发依赖冲突与 `manifest.json` 权限混乱。SFMC 采用**全自动装配管线**解决这一痛点: +当安装了多个模块时,SFMC 会在开服前自动合并模块代码并注入权限: ```bash -# 仅执行编译组装,不部署到世界 +# 仅执行编译与组装,不写入世界目录 sfmc> mod build -# 组装并部署,随后向运行中的 BDS 发出热重载请求(日常调试推荐) +# 组装并部署至世界,并向运行中的 BDS 请求热重载 sfmc> mod reload -# 仅组装并部署文件,不触发游戏内重载(之后需在控制台手动 reload) +# 仅组装并部署文件,不发送 reload 指令 sfmc> mod reload --build-only ``` -### 部署路径一览 +### 产物路径一览 -| 阶段 / 产物 | 落盘路径 | 作用说明 | -| :------------------ | :----------------------------------------------------- | :------------------------------------------------------- | -| **中间产物** | `/packs/_build/sfmc-modules/` | 经过打包工具(esbuild)混淆编译后的暂存目录。 | -| **世界行为包 (BP)** | `/worlds//behavior_packs/sfmc-modules/` | 实际注入当前游戏世界的最终行为包产物。 | -| **世界资源包 (RP)** | `/worlds//resource_packs/sfmc-modules-rp/` | 若启用的模块包含材质、UI 或音频资源,将自动生成配套 RP。 | -| **部署清单** | BP 根目录下 `sfmc-deploy-catalog.json` | 记录本次构建所包含的模块版本哈希,供装载闸门快速比对。 | +| 产物类型 | 输出路径 | 说明 | +| :--- | :--- | :--- | +| **中间产物** | `/packs/_build/sfmc-modules/` | esbuild 编译后的暂存文件。 | +| **世界行为包 (BP)** | `/worlds//behavior_packs/sfmc-modules/` | 实际注入当前世界的行为包产物。 | +| **世界资源包 (RP)** | `/worlds//resource_packs/sfmc-modules-rp/` | 若启用的模块携带贴图、UI 或音效资源,自动生成配套 RP。 | +| **部署清单** | BP 根目录下 `sfmc-deploy-catalog.json` | 记录本次构建所包含的模块版本哈希。 | -### 装配流水线四步走 +### 装配流程 -### 收集模块契约 -扫描 `modules/packages/*/sapi` 下的所有子模块,读取其 `manifest.json` 声明的依赖项与所需权限。 - -### 过滤启停白名单 +### 收集模块清单 +扫描 `modules/packages/*/sapi` 下的所有模块,读取其 `manifest.json` 声明的依赖与权限。 -严格依照 `modules/module-lock.json` 中标记为 `"enabled": true` 的项进行过滤。未启用的模块绝对不会被打入行为包。 +### 过滤启停状态 +依据 `modules/module-lock.json` 中标记为 `"enabled": true` 的模块进行筛选,禁用模块不参与编译。 -### 统一转译与聚合打包 - -以 `installHostBootstrap()` 宿主脚本为入口,通过现代化打包引擎将各模块的生命周期、事件侦听器与指令注册代码编译整合为一个高内聚的 `scripts/main.js`。 - -### 部署到世界并更新清单 - -将生成产物同步至当前世界的 `behavior_packs` 目录,并原子化更新 `world_behavior_packs.json` 启用声明。 +### 编译整合 +以 `installHostBootstrap()` 宿主脚本为入口,使用 esbuild 将各模块代码编译合并为 `scripts/main.js`。 +### 部署至世界 +将生成产物同步至世界的 `behavior_packs` 目录,并更新 `world_behavior_packs.json` 声明。 -:::note 空包保护 -即使服主关闭了所有功能模块,装配引擎仍会生成一个合法的空行为包骨架,确保服务端不会因资源丢失而报错。 +:::note 空包容底 +若服主禁用了所有模块,装配系统仍会生成一个合法的空行为包骨架,确保服务端不会因资源包缺失报错。 ::: -## 4. 本地开发与联调(--link) +## 4. 本地开发挂接(--link) -模块开发者无需每次都将未测试的代码发布到 npm。在本地开发时,运维机可通过软链接(Symlink)直连作者的源码仓库: +模块开发者在本地开发时,可通过软链接(Symlink)挂接作者本地仓库进行调试: ```bash -# 通过 dir: 源与 --link 参数挂接本地模块仓 +# 通过 dir: 源与 --link 参数挂接本地模块 sfmc> mod install my-feature --from dir:D:/WorkSpace/my-feature --link -# 启用并编译 +# 启用并重载 sfmc> mod enable my-feature sfmc> mod reload ``` -配合 VS Code / Cursor 专属扩展 **「SFMC Module」** 的 Watch 功能,在作者仓中每次按下保存,代码即可毫秒级热同步并通知 BDS 重新装载! +配合 VS Code / Cursor 官方扩展 **SFMC Module** 的 Watch 功能,保存代码时扩展会自动增量编译并通知 BDS 热重载。 -## 5. 官方推荐模块目录 +## 5. 模块索引目录 -以下列表在构建文档站时自动拉取自官方 `sfmc-modules` 索引库: +以下模块列表在构建文档站时自动拉取自官方 `sfmc-modules` 索引库: -> 想要调用官方模块暴露的跨模块接口?请查阅 [模块服务调用目录](../api/modules/index.md)。 - +> 跨模块调用接口请查阅 [模块服务调用目录](../api/modules/index.md)。 diff --git a/docs/zh/guide/qq-bridge.mdx b/docs/zh/guide/qq-bridge.mdx index f8747281..b8e6e1c8 100644 --- a/docs/zh/guide/qq-bridge.mdx +++ b/docs/zh/guide/qq-bridge.mdx @@ -2,11 +2,11 @@ import { Tab, Tabs, Steps } from "@rspress/core/theme"; # QQ 互通与机器人集成 -SFMC 内置了工业级的多端互通网关服务(`qq-bridge`),能够无缝桥接 Minecraft 服务器与 QQ 群聊生态,提供**群服双向聊天互通、全服状态监控、游戏内身份绑定、入服白名单审批与游戏事件批量播报**等核心能力。 +SFMC 内置了多端互通网关服务(`qq-bridge`),连接 Minecraft 服务器与 QQ 群,提供**群服双向聊天互通、服务器状态查询、游戏内身份绑定、入服白名单审批与游戏事件聚合播报**等功能。 ## 1. 架构拓扑与双后端模型 -`qq-bridge` 作为独立的伴生守护进程运行,支持两种完全不同的通信协议架构: +`qq-bridge` 作为独立的伴生守护进程运行,支持两种不同的通信协议架构: ```mermaid flowchart TD @@ -33,7 +33,7 @@ flowchart TD | 对比维度 | 腾讯官方开放平台(`official`,默认推荐) | LLBot / OneBot 11(`llbot`) | | :------------- | :----------------------------------------------------- | :------------------------------------------------------ | | **接入方式** | 官方机器人开发凭证(AppID / Secret / OpenID) | 基于本地 QQ 客户端框架(如 LiteLoaderQQNT + OneBot 11) | -| **封号风险** | **零风险**。走腾讯官方 OpenAPI 与标准审核通道。 | 存在传统“小号挂机”被风控或冻结的潜在风险。 | +| **封号风险** | 官方渠道。走腾讯官方 OpenAPI 与标准审核通道。 | 存在传统小号挂机被风控的潜在风险。 | | **交互能力** | 支持原生 Markdown 格式排版、富文本按钮与指令交互面板。 | 纯文本消息与数字编号快捷交互。 | | **入站方式** | 开放平台 WebSocket Gateway 或 HTTPS Webhook。 | LLBot 反向 WebSocket(监听本地 `3002` 端口)。 | | **出站方式** | `db-server` 直调 OpenAPI 发送主动推群消息。 | `db-server` 请求 LLBot HTTP 服务(默认 `3004` 端口)。 | @@ -172,12 +172,12 @@ WebSocket 模式另执行 `sfmc> /restart qq`。 ## 4. 群内交互指令体系(无需进入游戏) -`qq-bridge` 会在消息入库前进行实时拦截与指令模式匹配。同一套指令在两端呈现出极致契合其平台特性的交互形式: +`qq-bridge` 会在消息入库前进行解析与指令路由。同一套指令支持官方开放平台与 LLBot 两种形式: ```mermaid flowchart LR Msg[群成员发送消息] --> CommandCheck{是否是指令?} - CommandCheck -->|是,官方需开启接收所有消息或 @机器人| CommandRouter[内置指令路由引擎] + CommandCheck -->|是,官方需开启接收所有消息或 @机器人| CommandRouter[内置指令路由] CommandCheck -->|否,官方已 @机器人或使用 LLBot| ChatRelay[写入消息中继队列] CommandCheck -->|否,官方未 @机器人| Ignore[不转发游戏] @@ -204,15 +204,15 @@ flowchart LR | 指令触发词 | 功能与返回内容 | | :------------------------ | :------------------------------------------------------------------------------------ | -| `菜单` / `help` / `/help` | 展示常用交互面板。官方端呈现为精美 Markdown 按钮卡片;LLBot 呈现为序号索引。 | -| `ping` / `/ping` | 探测网关连通性与服务心跳延迟。 | +| `菜单` / `help` / `/help` | 展示常用交互面板。官方端为 Markdown 按钮卡片;LLBot 为序号索引。 | +| `ping` / `/ping` | 探测网关连通性与服务响应延迟。 | | `查服` / `status` / `状态` | 查询服务器运行摘要:运行状态、在线人数、世界日、难度与更新时间;运维详情移至管理员自检。 | | `online` / `在线` | 列出在线玩家名称,长名单自动连续分段。 | | `绑定` / `bind` | 向系统申请一个 6 位短验证码,用于在游戏内完成身份认证。 | | `我的绑定` / `whoami` | 查询当前 QQ 账号已绑定的 Minecraft 正版/离线角色名称。 | | `解绑` / `unbind` | 解除当前账号与游戏角色的映射绑定。 | -| `申请入服 <游戏名>` | 提交白名单入服申请(直接进入管理员审批工作流)。 | -| `频道` / `channel` | 诊断当前双向互通频道的连接状态与健康度。 | +| `申请入服 <游戏名>` | 提交白名单入服申请(进入管理员审批流程)。 | +| `频道` / `channel` | 查看当前双向互通频道的连接状态。 | ### 管理员高级管理指令(仅限 `official.admin_openids` 成员) @@ -238,11 +238,11 @@ flowchart LR - `db-server` 读取来源频道的「转发到 QQ」开关,开启时使用该频道前缀推入 QQ 群。新频道默认开启,可在频道设置中关闭。 3. **防循环与去重机制(Anti-Looping)**: - 严格拦截并丢弃机器人自身发送的消息。 - - 底层设有 **5 秒滑动窗口消息 ID 去重缓存**,彻底杜绝多端回环与重复轰炸。 + - 底层设有 **5 秒滑动窗口消息 ID 去重缓存**,杜绝多端回环与重复推送。 -## 6. 入服白名单与自动化审批工作流 +## 6. 入服白名单与审批工作流 -SFMC 颠覆了传统“手动改 `whitelist.json` 然后在控制台重载”的繁琐流程,开创了完全闭环的异步审批流: +SFMC 提供了基于 QQ 群交互的异步白名单审批流程: ```mermaid sequenceDiagram @@ -269,14 +269,14 @@ sequenceDiagram DB-->>User: QQ 群通知:"恭喜 Steve,您的入服申请已通过!" ``` -:::important 为什么不直接物理篡改 allowlist.json? -若外部 Node 服务直接强制覆盖写入 BDS 正在读写的 `allowlist.json` 文件,极易导致文件锁冲突与存档数据损坏。 -SFMC 通过官方 `@minecraft/server-admin` 原生安全接口注入白名单,即使 BDS 停机维护,所有审批也能在数据库中安全积压,**待 BDS 再次开机时全自动批量应用生效**。 +:::important 为什么不直接修改 allowlist.json? +若外部 Node 服务直接覆盖写入 BDS 正在读写的 `allowlist.json` 文件,容易导致文件锁冲突或数据不一致。 +SFMC 通过官方 `@minecraft/server-admin` 原生接口写入白名单。即使 BDS 停机维护,审批记录也会暂存在数据库中,**待 BDS 启动后批量应用生效**。 ::: -## 7. 智能事件节流与聚合广播(Event Throttle) +## 7. 事件节流与聚合广播(Event Throttle) -为了遵守腾讯 QQ 开放平台的主动推群频率配额(约 20 条/分钟、1000 条/群/天),SFMC 内置了**窗口时间聚合引擎(Window Aggregator)**: +为了避免群内频繁刷屏并符合平台频率限制,SFMC 支持在指定时间窗口内聚合推送事件: ```text 时间轴 (Timeline) ──────────────────────────────────────────► @@ -300,8 +300,8 @@ SFMC 通过官方 `@minecraft/server-admin` 原生安全接口注入白名单, - `stop`: BDS 正常停止 - `crash`: BDS 非正常意外崩溃退出(附带退出代码,自动重启前先行告警) -:::note 降噪哲学 -为了保障群聊日常交流体验,成就达成通知、每一条高频阵亡以及日常聊天镜像**绝不混入事件通道**,确保推送内容高价值、无噪音。 +:::note 事件过滤说明 +成就达成等高频非关键事件默认不会推入事件通道,避免影响群聊日常阅读。 ::: ## 8. 常见排障清单 diff --git a/docs/zh/guide/services.mdx b/docs/zh/guide/services.mdx index 95a2774b..bd0ae421 100644 --- a/docs/zh/guide/services.mdx +++ b/docs/zh/guide/services.mdx @@ -2,91 +2,88 @@ import { Steps } from "@rspress/core/theme"; # 服务编排与管理 -SFMC 采用**解耦的多进程伴生架构**。原生 Bedrock Dedicated Server(BDS)专注于高性能游戏世界模拟,而数据持久化、多端通信与附加包版本管控等重量级操作则由伴生的 Node.js 守护进程承担。 +SFMC 采用多进程伴生架构:Bedrock Dedicated Server(BDS)负责运行游戏逻辑,数据持久化、QQ 互通与行为包装配则由伴随的 Node.js 守护进程承担。 -## 1. 伴生架构与进程全景 +## 1. 进程架构与组件职责 -整个平台由以下四个核心子系统分工协作构成: +平台由以下核心子系统协同构成: ```mermaid flowchart TD subgraph HostProcess ["宿主与守护层 (Node.js 22+)"] - CLI["SFMC CLI / REPL 交互主控"] - DB["db-server (:3001)
SQLite 守护 & 配置中枢"] - QQ["qq-bridge (:3002)
官方 Bot / LLBot 网关"] - GATE["装载闸门 (Load Gate)
代码校验与行为包装配"] + CLI["SFMC CLI / 控制台"] + DB["db-server (:3001)
SQLite 守护与配置服务"] + QQ["qq-bridge (:3002)
官方机器人 / LLBot 网关"] + GATE["启动前装载检查 (Load Gate)
代码校验与行为包装配"] end subgraph NativeBDS ["原生游戏层 (C++)"] - BDS["Bedrock Dedicated Server "] - SAPI["Script API 运行时
(sfmc-modules 聚合行为包)"] + BDS["Bedrock Dedicated Server"] + SAPI["Script API 运行时
(sfmc-modules 行为包)"] end - CLI -->|控制 / 观测| DB - CLI -->|控制 / 观测| QQ - CLI -->|拉起前预检| GATE + CLI -->|控制 / 查看| DB + CLI -->|控制 / 查看| QQ + CLI -->|拉起前检查| GATE GATE -->|通过并装配| BDS SAPI <== Loopback HTTP (127.0.0.1:3001) ==> DB QQ <== 消息互通 (IPC/REST) ==> DB ``` -| 组件名称 | 运行时 | 监听端口(默认) / 协议 | 核心职责 | -| :------------ | :------ | :-------------------------- | :---------------------------------------------------------------------------------------------------------------- | -| **db-server** | Node.js | `3001` (TCP, Loopback) | 依托 `node:sqlite` 原生驱动,为 SAPI 模块提供透明数据库读写、跨模块 RPC 转发及平台全局配置(`configs/all`)下发。 | -| **qq-bridge** | Node.js | `3002` (TCP) / 外联 OpenAPI | 负责与腾讯 QQ 开放平台 WebSocket 网关或本地 LLBot (OneBot 11) 通信,实现群服消息双向路由与入服白名单联动。 | -| **装载闸门** | Node.js | 阶段性执行,无端口 | 在拉起 BDS 前执行代码哈希校验、附加包更新源探测、多模块行为包动态编译与 `server-net` 权限校验。 | +| 组件名称 | 运行时 | 默认端口 / 协议 | 核心职责 | +| :--- | :--- | :--- | :--- | +| **db-server** | Node.js | `3001` (TCP, Loopback) | 基于 `node:sqlite` 为 SAPI 模块提供数据库读写、跨模块 RPC 转发及平台配置(`configs/all`)下发。 | +| **qq-bridge** | Node.js | `3002` (TCP) / 外部 OpenAPI | 连接腾讯 QQ 开放平台或本地 LLBot (OneBot 11),处理群聊与游戏消息路由。 | +| **装载检查** | Node.js | 无网络端口(启动阶段运行) | 在启动 BDS 前比对代码指纹、处理待装附加包,并在有变更时自动编译生成行为包。 | -## 2. 启动时序与装载闸门(Load Gate) +## 2. 启动时序与装载检查(Load Gate) -### 严格的启动依赖拓扑 +### 启动时序 -在使用 `/start -all` 命令时,SFMC 会严格按照依赖依赖图顺序逐级拉起各进程: +执行 `/start -all` 命令时,系统按以下顺序依次启动各进程: ```mermaid flowchart LR - db[1. db-server 就绪] --> qq[2. qq-bridge 联通] + db[1. 启动 db-server] --> qq[2. 启动 qq-bridge] qq --> llbot{启用 LLBot?} - llbot -->|是| bot[3. 拉起 LLBot 进程] - llbot -->|否| gate[4. 执行装载闸门] + llbot -->|是| bot[3. 启动 LLBot 进程] + llbot -->|否| gate[4. 执行装载检查] bot --> gate - gate -->|校验一致并通过| bds[5. 拉起原生 BDS] - gate -->|构建错误或冲突| stop[阻断启动并输出告警] + gate -->|校验通过| bds[5. 启动原生 BDS] + gate -->|编译错误或冲突| stop[终止启动并输出提示] ``` -> **为什么 SAPI 不能早于 db-server 启动?** -> Minecraft BDS 在触发 `system.beforeEvents.startup` 钩子时,SFMC SDK 的 `ConfigManager` 必须立刻向 `127.0.0.1:3001` 请求初始配置快照(包含模块 Token、系统配置与权限)。若 db-server 未提前就绪,模块将因无法获取合法身份而拒绝加载。 +:::tip 为什么 db-server 必须优先启动? +Minecraft BDS 触发 `system.beforeEvents.startup` 钩子时,SFMC SDK 的 `ConfigManager` 会立刻向 `127.0.0.1:3001` 请求初始配置快照(包含模块 Token、系统配置与权限表)。若 db-server 未提前就绪,模块因无法获取 Token 将无法正常初始化。 +::: -### 装载闸门的工作流程 +### 装载检查流程 -装载闸门是保障服务器数据安全与代码一致性的核心屏障: +启动 BDS 前,系统会自动执行前置检查与构建: -### 扫描与归档收件箱附加包 -自动扫描 `/packs/` 目录中的第三方 `.mcpack`、`.mcaddon` 与压缩归档。核对世界目录中已有的 UUID 与版本,自动安装或增量更新,详情见 [附加包管理](./addons.md)。 +### 扫描与安装待装附加包 +检查 `/packs/` 目录中的第三方 `.mcpack`、`.mcaddon` 与压缩包。若有新文件,自动解压部署至世界目录,详见 [附加包管理](./addons.md)。 -### 指纹比对与一致性核验 +### 代码指纹比对 +读取世界行为包内的部署清单 `sfmc-deploy-catalog.json`,与当前本地模块源码的 SHA 指纹进行比对。 -读取目标世界行为包内的部署清单 `sfmc-deploy-catalog.json`,与当前本地的 `modules/module-lock.json`(启停状态)及各模块源码的 SHA 指纹进行快速哈希比对。 - -### 按需全量编译与权限注入 - -若检测到模块增删、启停状态改变或 TypeScript 源码变更,系统会自动调用轻量打包器将所有 `enabled` 模块重编整合为 `sfmc-modules` 行为包,并强制写入 BDS 所必需的 `server-net` 原生通信权限。 - -### 打印装载摘要与阻断熔断 - -在控制台清晰打印本次加载的模块名称、版本号与耗时。若任何环节发生语法编译错误或依赖死锁,**装载闸门将坚决终止 BDS 启动**,避免损坏世界存档。 +### 增量编译与行为包装配 +若检测到模块增删、启停状态改变或源码发生变动,自动调用打包工具编译所有已启用模块,生成世界行为包产物。 +### 放行或阻断 +若编译或依赖校验失败,**系统将终止 BDS 启动**并输出错误提示,避免加载损坏的行为包导致世界异常。 -## 3. 运维控制命令速查 +## 3. 运维控制命令 -在 `sfmc` 交互式终端(REPL)中,可通过斜杠 `/` 或直接输入命令进行全方位编排: +在 `sfmc` 控制台中可执行以下管理命令: -### 服务起停与状态 +### 服务启停与状态 ```bash -# 一键启动所有关联服务(推荐开服使用) +# 启动所有服务 sfmc> /start -all # 单独启动或重启特定服务 @@ -96,27 +93,27 @@ sfmc> /restart bds # 查看当前各进程的 PID、运行时间与资源状态 sfmc> /status -# 平滑停止所有进程(安全存盘退出) +# 安全停止所有进程 sfmc> /stop -all ``` -### 日志流监控与标准输入 +### 日志查看与指令转发 -- **日志查看**:执行 `logs [-n 行数] [-f]` 实时跟踪指定服务日志(如 `logs bds -f`)。 -- **多源切换**:在 REPL 交互面板中,随时按下键盘 `TAB` 键或使用快捷键 `Ctrl + L`,可在 BDS 控制台、数据库中枢和 QQ 桥接的实时日志流间无缝切换。 -- **向原生 BDS 发送命令**:使用 `send` 指令向子进程标准输入(stdin)注入指令: +- **查看日志**:执行 `logs [-n 行数] [-f]` 跟踪指定服务日志(如 `logs bds -f`)。 +- **切换日志流**:在控制台中按 `TAB` 键或使用 `Ctrl + L`,可在各服务日志间切换。 +- **向 BDS 发送指令**:使用 `send` 命令向 BDS 控制台输入指令: ```bash - sfmc> send bds say §a[系统公告] 服务器将在 10 分钟后例行重启维护 + sfmc> send bds say §a[系统公告] 服务器将在 10 分钟后例行重启 sfmc> send bds op Steve ``` -## 4. 生产环境常驻与守护实践(Linux / Windows) +## 4. 生产环境常驻运行 -在长期运行的生产服务器上,推荐采用系统级服务管理器进行保活守护。 +在长期运行的生产服务器上,推荐使用系统服务管理器进行保活守护。 ### 方案 A:Linux `systemd` 服务守护(推荐) -创建 `/etc/systemd/system/sfmc.service`: +创建配置文件 `/etc/systemd/system/sfmc.service`: ```ini title="/etc/systemd/system/sfmc.service" [Unit] @@ -127,7 +124,6 @@ After=network.target Type=simple User=minecraft WorkingDirectory=/home/minecraft/sfmc-server -# 明确指定 Node.js 运行时与 SFMC_ROOT Environment="SFMC_ROOT=/home/minecraft/sfmc-server" Environment="PATH=/usr/local/bin:/usr/bin" ExecStart=/usr/local/bin/sfmc /start -all @@ -140,18 +136,19 @@ LimitNOFILE=65535 WantedBy=multi-user.target ``` -启用并启动守护服务: +加载并启动服务: ```bash sudo systemctl daemon-reload sudo systemctl enable --now sfmc + # 查看运行日志 sudo journalctl -u sfmc -f ``` -### 方案 B:PM2 进程管理方案 +### 方案 B:PM2 进程管理 -如果服务器已配置 PM2 环境,可创建 `ecosystem.config.cjs` 实施细粒度守护: +如果已有 PM2 环境,可创建 `ecosystem.config.cjs` 进行守护: ```javascript title="ecosystem.config.cjs" module.exports = { @@ -169,39 +166,39 @@ module.exports = { }; ``` -## 5. 网络拓扑与安全加固规范 +## 5. 端口与网络安全规范 -为了防止越权访问与恶意网络扫描,生产环境必须严格遵循以下端口隔离策略: +生产环境中请确保内部通信端口不暴露至公网: ```text - Internet - │ - [ UDP: 19132 ] ──► 仅放行游戏流量至 BDS - │ - ─────────────────── 防火墙边界 (Firewall Barrier) ─────────────────── - │ - [ 127.0.0.1 ] ──► 仅允许本机回环(Loopback) - ├── TCP: 3001 (db-server HTTP) - ├── TCP: 3002 (qq-bridge WS) - └── TCP: 3004 (LLBot HTTP API) + 公网流量 (Internet) + │ + [ UDP: 19132 ] ──► 仅放行游戏连接至 BDS + │ + ──────────── 防火墙边界 ──────────── + │ + [ 127.0.0.1 ] ──► 本地回环接口 + ├── TCP: 3001 (db-server HTTP 接口) + ├── TCP: 3002 (qq-bridge WebSocket 端口) + └── TCP: 3004 (LLBot HTTP API) ``` -| 端口号 | 协议 | 建议暴露范围 | 安全要求与防范建议 | -| :---------- | :--: | :--------------------- | :------------------------------------------------------------------------------------------------- | -| **`19132`** | UDP | **公网开放** | Minecraft Bedrock 官方游戏客户端连接端口。 | -| **`3001`** | TCP | **仅本机 (127.0.0.1)** | `db-server` 核心持久化接口。**严禁暴露至公网**。建议在 `db_config.json` 中配置强密钥 `http_auth`。 | -| **`3002`** | TCP | **仅本机 / 内网** | QQ 桥接 WebSocket 端口,供 LLBot 连接。 | -| **`3004`** | TCP | **仅本机 (127.0.0.1)** | LLBot OneBot 11 HTTP 监听端口。 | +| 端口号 | 协议 | 暴露范围 | 说明 | +| :--- | :---: | :--- | :--- | +| **`19132`** | UDP | **公网开放** | Minecraft 基岩版官方客户端连接端口。 | +| **`3001`** | TCP | **仅本机 (127.0.0.1)** | `db-server` 数据库接口。**严禁暴露至公网**。建议在 `db_config.json` 中配置密钥。 | +| **`3002`** | TCP | **仅本机 / 内网** | QQ 桥接 WebSocket 端口,供 LLBot 连接。 | +| **`3004`** | TCP | **仅本机 (127.0.0.1)** | LLBot OneBot 11 HTTP 监听端口。 | -在 Linux 云服务器上,可通过 `ufw` 快速巩固安全边界: +在 Linux 云服务器上,可通过 `ufw` 限制端口访问: ```bash -# 仅放行 Minecraft 游戏端口与 SSH +# 仅放行 SSH 与游戏端口 sudo ufw allow 22/tcp sudo ufw allow 19132/udp -# 显式拒绝外网对 3001 / 3002 的嗅探 + +# 拒绝外网对内部服务端口的访问 sudo ufw deny 3001/tcp sudo ufw deny 3002/tcp sudo ufw enable ``` - diff --git a/docs/zh/guide/troubleshooting.mdx b/docs/zh/guide/troubleshooting.mdx index 4f43c20d..6cd8f157 100644 --- a/docs/zh/guide/troubleshooting.mdx +++ b/docs/zh/guide/troubleshooting.mdx @@ -26,11 +26,11 @@ lsof -i :3001 --- -### 2. 数据库守护服务(db-server)异常 +### 2. 数据库服务(db-server)异常 -| 异常现象 | 核心根因 | 解决步骤 | +| 异常现象 | 可能原因 | 解决步骤 | | :--- | :--- | :--- | -| 启动瞬间秒退、报 `Cannot find module 'node:sqlite'` | Node.js 版本过低 | 将 Node.js 升级至 **`22.13.0` 或更高版本**(平台依赖 Node 内置的原生 SQLite 模块)。 | +| 启动报错 `Cannot find module 'node:sqlite'` | Node.js 版本过低 | 将 Node.js 升级至 **`22.13.0` 或更高版本**(平台依赖 Node 内置的原生 SQLite 模块)。 | | 数据持久化写入失败或报 `SQLITE_CORRUPT` | 磁盘异常或非正常断电损坏 | 停止所有服务,从 `/data/backups/` 备份文件夹中还原最近的 `.sqlite` 文件。 | | SAPI 模块调用数据服务报 `401 Unauthorized` | 模块鉴权 Token 失效 | 确认 SAPI 启动阶段 `ConfigManager.init()` 顺利获取了启动快照,重新执行 `mod reload`。 | @@ -41,9 +41,9 @@ curl http://127.0.0.1:3001/api/health --- -### 3. BDS 启动与行为包装载闸门 +### 3. BDS 启动与行为包装载 -1. **查看装载摘要**:检查终端控制台的装载输出与 `/.sfmc/logs/` 下的各服务独立日志。 +1. **查看装载日志**:检查控制台输出与 `/.sfmc/logs/` 下的各服务独立日志。 2. **手动编译打包验证**:在 sfmc 终端中执行 `mod build`,确认 esbuild 产物生成无 TypeScript 语法报错。 3. **确认附加包冲突**:检查 `worlds//behavior_packs/` 是否存在同名或旧版行为包残留(详见 [附加包管理](./addons.md))。 @@ -51,11 +51,11 @@ curl http://127.0.0.1:3001/api/health ### 4. 模块已启用(Enabled)但在游戏内无响应 -- **代码与配置生效边界**: +- **代码与配置生效机制**: - 若修改了 `sapi/src/` 中的 TypeScript 代码:执行 `sfmc> mod reload` 即可热重载生效。 - 若修改了 `configs/*.json` 或 `manifest.json`:必须**重启 BDS** 才能生效。 - **锁文件状态**:确认 `/modules/module-lock.json` 中该模块的 `enabled` 字段为 `true`。 -- **生命周期时序问题**:若模块在初始化阶段操作了实体或维度数据失败,检查 `ModuleRegistry.register` 是否应设置 `afterWorldLoad: true`。 +- **生命周期时序**:若模块在初始化阶段操作了实体或维度数据失败,检查 `ModuleRegistry.register` 是否应设置 `afterWorldLoad: true`。 - **前置依赖模块**:检查 `manifest.json` 中的 `dependencies` 是否均已安装且处于 `enabled` 状态。 --- diff --git a/docs/zh/index.mdx b/docs/zh/index.mdx index c7a29353..689cffe5 100644 --- a/docs/zh/index.mdx +++ b/docs/zh/index.mdx @@ -4,46 +4,46 @@ pageType: home hero: name: SFMC text: ScriptsForMinecraftServer - tagline: 原生 BDS 上的 Script API 行为包与 Node 仓顶服务 + tagline: 原生 BDS 上的 Script API 行为包与 Node 伴生服务 actions: - theme: brand text: 快速上手 link: /guide/ - theme: alt - text: 开发模块 + text: 模块开发 link: /dev/module-author - theme: alt - text: 模块仓库 + text: 模块索引 link: https://github.com/Tanya7z/sfmc-modules features: - - title: ⚡ 原生 SAPI 深度集成 - details: 基于 Minecraft 官方 Script API 标准,无需魔改客户端或专用加载器,轻量、稳定且性能卓越。 + - title: ⚡ 原生 SAPI 集成 + details: 基于 Minecraft 官方 Script API 标准,无需修改客户端,运行稳定。 link: /guide/ - - title: 🧩 模块化热装载生态 - details: 业务逻辑以独立模块包交付,支持使用 sfmc CLI 按需启停、动态加载与隔离运行。 + - title: 🧩 模块化动态装载 + details: 业务功能以独立模块交付,支持通过 CLI 按需启停并自动打包为行为包。 link: /guide/modules - - title: 🗄️ 跨进程数据与持久化 - details: 内置轻量 SQLite HTTP 守护进程与 Loopback 通信桥,为 Script API 提供安全的高性能数据读写与事务能力。 + - title: 🗄️ 数据持久化与服务 + details: 内置 SQLite HTTP 守护进程,为 SAPI 提供可靠的数据读写与事务能力。 link: /guide/services - - title: 🤖 多端互联与 QQ 机器人 - details: 内置 QQ 桥接服务,无缝集成官方开放平台与 LLBot,实现群服消息互通、白名单审核与事件告警。 + - title: 🤖 QQ 群服互通 + details: 内置 QQ 桥接服务,支持官方开放平台与 LLBot,实现群聊与游戏消息互通、白名单审核与事件通知。 link: /guide/qq-bridge - - title: 🛠️ 全栈 TypeScript 开发套件 - details: 提供开箱即用的 SDK、专属 ESLint 静态检查规则与类型定义,从脚手架到调试一气呵成。 + - title: 🛠️ TypeScript 开发支持 + details: 提供专属 SDK、ESLint 规则与完整类型定义,支持本地 Watch 热重载调试。 link: /dev/module-author - - title: 🔍 交互式故障排查向导 - details: 提供结构化自检流程与交互式诊断排障向导,秒级定位端口占用、权限冲突与启动异常。 + - title: 🔍 故障排查向导 + details: 提供结构化自检流程与交互式排障向导,快速定位端口冲突与服务异常。 link: /guide/troubleshooting ---

- 5 分钟编写你的第一个 SFMC 模块 + 快速编写你的第一个 SFMC 模块

- 规范化的生命周期,严格的类型约束,声明式的权限与命令注册。 + 规范的生命周期管理,声明式命令与权限注册。

@@ -55,7 +55,7 @@ Command.register( "hello", "hello.use", (player) => { - if (player) Msg.send(player, "§a你好,欢迎来到 SFMC 驱动的 Minecraft 服务器!"); + if (player) Msg.info("你好,欢迎来到 SFMC 驱动的服务器!", player); }, "发送问候消息", "feature-hello" @@ -66,7 +66,7 @@ ModuleRegistry.register({ afterWorldLoad: false, lifecycle: { registerPermissions() { - Permission.register("hello.use", "使用 hello 命令", 0); + Permission.register("hello.use", 0); }, }, }); diff --git a/modules/catalog.json b/modules/catalog.json deleted file mode 100644 index c9ec633c..00000000 --- a/modules/catalog.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://cdn.jsdelivr.net/gh/DogeLakeDev/ScriptsForMinecraftServer@main/modules/sdk/@sfmc-sdk/schemas/module_catalog.schema.json", - "version": 1, - "modules": [] -} diff --git a/modules/sdk/@sfmc-sdk/schemas/sapi-manifest.v2.schema.json b/modules/sdk/@sfmc-sdk/schemas/sapi-manifest.v2.schema.json index 46f195ee..e1e61836 100644 --- a/modules/sdk/@sfmc-sdk/schemas/sapi-manifest.v2.schema.json +++ b/modules/sdk/@sfmc-sdk/schemas/sapi-manifest.v2.schema.json @@ -36,21 +36,13 @@ "type": { "type": "string", "enum": ["core", "feature"], - "description": "已废弃:请删除。不可禁用改用 canDisable:false;默认启用改用 enabledByDefault" + "description": "已废弃:请删除。模块启停由服主通过 CLI/API 管理" }, "configKey": { "type": "string", "minLength": 1, "description": "对应 configs/.json" }, - "enabledByDefault": { - "type": "boolean", - "description": "安装时是否默认写入启用(缺省 true)" - }, - "canDisable": { - "type": "boolean", - "description": "是否允许通过 CLI/API 写入禁用状态(缺省 true;下次冷启动生效)" - }, "requires": { "type": "array", "items": { "type": "string", "minLength": 1 }, diff --git a/modules/sdk/@sfmc-sdk/src/contracts/index.ts b/modules/sdk/@sfmc-sdk/src/contracts/index.ts index 1cc8cc9b..72748319 100644 --- a/modules/sdk/@sfmc-sdk/src/contracts/index.ts +++ b/modules/sdk/@sfmc-sdk/src/contracts/index.ts @@ -2,6 +2,7 @@ * contracts/index.ts — 平台级共享类型 */ export type { ModuleCatalog, ModuleCatalogEntry, ModuleEntryPath, ModuleLock, ModuleRuntimeState } from "./module.js"; +export { isRetiredPlatformModule, PLATFORM_MONITORING_SERVICES, PLATFORM_SERVICE_OWNER, RETIRED_PLATFORM_MODULES } from "./platform-capabilities.js"; export { UI_DOCUMENT_FORMAT_VERSION } from "./ui-document.js"; export type { UiActionDefinition, diff --git a/modules/sdk/@sfmc-sdk/src/contracts/platform-capabilities.ts b/modules/sdk/@sfmc-sdk/src/contracts/platform-capabilities.ts new file mode 100644 index 00000000..f277341c --- /dev/null +++ b/modules/sdk/@sfmc-sdk/src/contracts/platform-capabilities.ts @@ -0,0 +1,7 @@ +/** 已收编至平台的模块及其兼容服务名称。旧配置、包目录和历史表保留。 */ +export const RETIRED_PLATFORM_MODULES = ["monitor"] as const; +export const PLATFORM_MONITORING_SERVICES = ["tps.current", "tps.status", "monitor.metrics"] as const; +export const PLATFORM_SERVICE_OWNER = "_platform"; +export function isRetiredPlatformModule(id: string): boolean { + return RETIRED_PLATFORM_MODULES.some((value) => value === id); +} diff --git a/modules/sdk/@sfmc-sdk/src/module-loader/install.ts b/modules/sdk/@sfmc-sdk/src/module-loader/install.ts index 7a84460b..c2fc6091 100644 --- a/modules/sdk/@sfmc-sdk/src/module-loader/install.ts +++ b/modules/sdk/@sfmc-sdk/src/module-loader/install.ts @@ -29,7 +29,10 @@ import { clearDbModuleContext, getDbClient, setDbModuleContext } from "../sapi/d import { applyDebugFromVariables, initSentryIfConfigured } from "../sapi/diagnostics/sentry.js"; import { Command } from "../sapi/runtime/command.js"; import { debug } from "../sapi/runtime/debug-log.js"; -import { clearServiceModuleContext, getServiceClient, setServiceModuleContext } from "../sapi/service/client.js"; +import { getRuntimeMetrics, getRuntimeTpsStatus, startRuntimeMonitoring, stopRuntimeMonitoring } from "../sapi/runtime/monitoring.js"; +import { Msg } from "../sapi/runtime/msg.js"; +import { Permission } from "../sapi/runtime/permission.js"; +import { clearServiceModuleContext, createServiceClient, getServiceClient, setServiceModuleContext } from "../sapi/service/client.js"; import type { DataAdapter } from "./data-adapter.js"; import { createHttpDataAdapter } from "./http-data-adapter.js"; import { ConfigManager } from "./internal/config-manager.js"; @@ -62,6 +65,22 @@ let _installed = false; export function installHostBootstrap(options: InstallOptions = {}): HostBackend { if (_installed) return _bootstrapBackend(); _installed = true; + const platformServices = createServiceClient("_platform", ""); + platformServices.provide("tps.current", () => getRuntimeTpsStatus().tps); + platformServices.provide("tps.status", () => getRuntimeTpsStatus()); + platformServices.provide("monitor.metrics", () => { + const metrics = getRuntimeMetrics(); + return metrics ? { ...metrics, grade: getRuntimeTpsStatus().grade, totalLoadedChunks: metrics.chunkEstimate } : null; + }); + // 保留 /c:status 习惯;已有 monitor 的命令声明仍可覆盖,避免重复注册原生命令。 + if (!Command.list.status) Command.register("status", Permission.Admin, player => { + const metrics = getRuntimeMetrics(); + const text = ["§e===== 服务器监控 =====", `§7TPS: §f${metrics?.tps?.toFixed(2) ?? "未知"} §7/ 20.00`, + `§7在线人数: §f${metrics?.onlineCount ?? "未知"}`, + ...[["minecraft:overworld", "主世界"], ["minecraft:nether", "下界"], ["minecraft:the_end", "末地"]].map(([id, label]) => `§7${label}实体: §f${metrics?.entities[id!] ?? "未知"}`), + `§7视距区块估算: §f${metrics?.chunkEstimate ?? "未知"}`].join("\n"); + if (player) Msg.info(text, player); else debug.i("SFMC", text); + }, "查看服务器综合负载"); // 将 BDS SAPI system 注入 module-loader 的 host 抽象(避免模块 loader 顶层硬依赖 @minecraft/server) @@ -110,6 +129,7 @@ export function installHostBootstrap(options: InstallOptions = {}): HostBackend )?.afterEvents; let worldLoadFired = false; + let stopMonitoring: (() => void) | undefined; const runStartup = async () => { // Sentry / 控制台 debug:DSN 与 sfmc_debug 均缺省关闭 @@ -133,6 +153,7 @@ export function installHostBootstrap(options: InstallOptions = {}): HostBackend announceLoaded(); // 若环境无 worldLoad 事件(如 1.18 稳定版)或事件在 ConfigManager 就绪前已触发,立即执行 bootAfterWorldLoad if (!worldAfter?.worldLoad?.subscribe || worldLoadFired) { + stopMonitoring ??= startRuntimeMonitoring(); await ModuleRegistry.bootAfterWorldLoad(); } }; @@ -164,6 +185,7 @@ export function installHostBootstrap(options: InstallOptions = {}): HostBackend if (worldAfter?.worldLoad?.subscribe) { worldAfter.worldLoad.subscribe(() => { worldLoadFired = true; + stopMonitoring ??= startRuntimeMonitoring(); if (!ConfigManager.isReady()) return; ModuleRegistry.bootAfterWorldLoad(); }); @@ -172,6 +194,7 @@ export function installHostBootstrap(options: InstallOptions = {}): HostBackend if (sysBefore?.shutdown?.subscribe) { sysBefore.shutdown.subscribe(() => { try { + stopMonitoring?.(); ModuleRegistry.teardown(); } catch {} }); @@ -185,6 +208,6 @@ function _bootstrapBackend(): HostBackend { bindDataAdapter: (adapter: DataAdapter) => { ConfigManager.bindDataAdapter(adapter); }, - dispose: () => undefined, + dispose: () => stopRuntimeMonitoring(), }; } diff --git a/modules/sdk/@sfmc-sdk/src/module-loader/manifest-schema.ts b/modules/sdk/@sfmc-sdk/src/module-loader/manifest-schema.ts index db92a82b..22b8b7a4 100644 --- a/modules/sdk/@sfmc-sdk/src/module-loader/manifest-schema.ts +++ b/modules/sdk/@sfmc-sdk/src/module-loader/manifest-schema.ts @@ -20,10 +20,6 @@ export type ManifestV2 = { requires?: ServiceEntry[]; } | undefined; - /** 安装时是否默认写入启用(缺省 true)。 */ - enabledByDefault?: boolean | undefined; - /** 是否允许 CLI/API 写入禁用(缺省 true;下次冷启动生效)。 */ - canDisable?: boolean | undefined; notes?: string | undefined; }; diff --git a/modules/sdk/@sfmc-sdk/src/module-loader/manifest.ts b/modules/sdk/@sfmc-sdk/src/module-loader/manifest.ts index fc4e47fa..d12787e7 100644 --- a/modules/sdk/@sfmc-sdk/src/module-loader/manifest.ts +++ b/modules/sdk/@sfmc-sdk/src/module-loader/manifest.ts @@ -97,8 +97,6 @@ export function validateManifestV3(input: unknown): ValidationResult configKey: r.configKey as string, requires: r.requires as string[], permissions: r.permissions as string[], - ...(typeof r.enabledByDefault === "boolean" ? { enabledByDefault: r.enabledByDefault } : {}), - ...(typeof r.canDisable === "boolean" ? { canDisable: r.canDisable } : {}), ...(r.services && typeof r.services === "object" ? { services: r.services as ManifestV2["services"] } : {}), ...(typeof r.notes === "string" ? { notes: r.notes } : {}), ...(normalized ? { semantic: normalized } : {}), @@ -108,7 +106,7 @@ export function validateManifestV3(input: unknown): ValidationResult /** * 收集 v2 与 v3 共享的核心顶层字段校验问题。 - * 统一收敛必填字段(id、name、configKey、requires、permissions)与通用配置(enabledByDefault、canDisable、services), + * 统一收敛必填字段(id、name、configKey、requires、permissions)、services 与停用字段检查, * 避免在 v2 与 v3 之间维护两套重复的校验规则与提示文案。 * * @param r 原始输入对象字典。 @@ -125,8 +123,11 @@ function collectCoreIssues(r: Record, expectedVersion: 2 | 3): requireNonEmptyString(issues, r.configKey, "configKey"); requireStringArray(issues, r.requires, "requires"); requireStringArray(issues, r.permissions, "permissions"); - optionalBoolean(issues, r.enabledByDefault, "enabledByDefault"); - optionalBoolean(issues, r.canDisable, "canDisable"); + for (const field of ["enabledByDefault", "canDisable"]) { + if (Object.prototype.hasOwnProperty.call(r, field)) { + issues.push(issueConstMismatch(field, "省略(字段已停用,请删除)", describe(r[field]))); + } + } if (!isPlainObject(r.services)) { issues.push(issueInvalidType("services", ManifestExpected.object)); } else { @@ -237,12 +238,6 @@ function optionalArray(issues: ManifestIssue[], value: unknown, path: string): v } -function optionalBoolean(issues: ManifestIssue[], value: unknown, path: string): void { - if (value !== undefined && typeof value !== "boolean") { - issues.push(issueInvalidType(path, ManifestExpected.boolean)); - } -} - /** * v2 → v3 迁移。保守策略: * - 所有 v2 必需字段原样复制; @@ -259,8 +254,6 @@ export function migrateV2toV3(v2: ManifestV2): ManifestV3 { configKey: v2.configKey, requires: Array.isArray(v2.requires) ? [...v2.requires] : [], permissions: Array.isArray(v2.permissions) ? [...v2.permissions] : [], - ...(typeof v2.enabledByDefault === "boolean" ? { enabledByDefault: v2.enabledByDefault } : {}), - ...(typeof v2.canDisable === "boolean" ? { canDisable: v2.canDisable } : {}), ...(v2.services ? { services: cloneServices(v2.services) } : {}), ...(v2.notes !== undefined ? { notes: v2.notes } : {}), }; diff --git a/modules/sdk/@sfmc-sdk/src/module-loader/runtime.ts b/modules/sdk/@sfmc-sdk/src/module-loader/runtime.ts index 38450d5e..13007f6a 100644 --- a/modules/sdk/@sfmc-sdk/src/module-loader/runtime.ts +++ b/modules/sdk/@sfmc-sdk/src/module-loader/runtime.ts @@ -1,3 +1,4 @@ +import { isRetiredPlatformModule } from "../contracts/platform-capabilities.js"; import type { ConfigClient } from "../sapi/config/client.js"; import type { DbClient } from "../sapi/db/client.js"; import { debug } from "../sapi/runtime/debug-log.js"; @@ -117,6 +118,7 @@ function applyModuleAuthContext(id: ModuleId): ModuleServices | undefined { export class ModuleRegistry { /** 注册模块描述符(构建时各模块包调用)。 */ static register(descriptor: ModuleDescriptor): void { + if (isRetiredPlatformModule(descriptor.id)) return; gModuleState.descriptors.push(descriptor); } diff --git a/modules/sdk/@sfmc-sdk/src/sapi/runtime/index.ts b/modules/sdk/@sfmc-sdk/src/sapi/runtime/index.ts index 34c29f62..07bfd565 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/runtime/index.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/runtime/index.ts @@ -20,4 +20,5 @@ export * from "./permission.js"; export * from "./command.js"; export * from "./httpdb.js"; export * from "./economy.js"; +export { getRuntimeMetrics, getRuntimeTpsStatus, type RuntimeMetrics } from "./monitoring.js"; export { SFMC_SAPI_RUNTIME_VERSION } from "./version.js"; diff --git a/modules/sdk/@sfmc-sdk/src/sapi/runtime/monitoring.ts b/modules/sdk/@sfmc-sdk/src/sapi/runtime/monitoring.ts new file mode 100644 index 00000000..fbb7df11 --- /dev/null +++ b/modules/sdk/@sfmc-sdk/src/sapi/runtime/monitoring.ts @@ -0,0 +1,120 @@ +/** 平台基础运行监控:不依赖可选 monitor 模块,随行为包宿主启动。 */ +import { system, world } from "@minecraft/server"; +import { HttpDB, SafeHttpMethod } from "./httpdb.js"; +import { TickSampler } from "./tick-sampler.js"; + +export interface RuntimeMetrics { + bootId: string; + tps: number | null; + onlineCount: number; + entities: Record; + entitiesUpdatedAt: number | null; + /** 按客户端视距累加的估算,不能视为真实已加载区块数。 */ + chunkEstimate: number | null; +} + +type MonitoringState = { sampler: TickSampler; snapshot?: RuntimeMetrics; stop?: () => void }; +// SDK 子路径独立打包,采样状态必须跨 install 与 runtime 门面共享。 +const globals = globalThis as unknown as { __sfmcRuntimeMonitoringState?: MonitoringState }; +const state = (globals.__sfmcRuntimeMonitoringState ??= { sampler: new TickSampler() }); +const sampler = state.sampler; + +/** 模块可直接读取平台快照,无需安装 monitor 或访问任意数据库。 */ +export function getRuntimeMetrics(): RuntimeMetrics | undefined { + return state.snapshot + ? { ...state.snapshot, tps: sampler.current(), entities: { ...state.snapshot.entities } } + : undefined; +} + +export function getRuntimeTpsStatus() { + const tps = sampler.current(); + const grade = tps === null ? "unknown" : tps >= 19.5 ? "green" : tps >= 15 ? "yellow" : tps >= 10 ? "gold" : "red"; + return { tps, grade, text: `§7[TPS] §f${tps?.toFixed(2) ?? "未知"} §7/ 20.00` }; +} + +export function stopRuntimeMonitoring(): void { + state.stop?.(); +} + +/** 世界加载完成后启动;每 tick 采 TPS,每 5 秒报告,实体枚举每约 30 秒一次。 */ +export function startRuntimeMonitoring(): () => void { + if (state.stop) return state.stop; + const bootId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + let busy = false; + let disposed = false; + let lastEntities = 0; + let lastWarning = 0; + const warn = () => { + if (Date.now() - lastWarning > 60_000) { + lastWarning = Date.now(); + console.warn("[SFMC] 运行指标上报失败,请检查数据服务连接与鉴权"); + } + }; + const report = () => { + if (busy || disposed) return; + try { + const players = world.getAllPlayers(); + let entities = state.snapshot?.entities ?? {}; + let entitiesUpdatedAt = state.snapshot?.entitiesUpdatedAt ?? null; + if (Date.now() - lastEntities >= 30_000) { + entities = {}; + for (const id of ["minecraft:overworld", "minecraft:nether", "minecraft:the_end"]) { + try { + entities[id] = world.getDimension(id).getEntities().length; + } catch { + entities[id] = null; + } + } + lastEntities = Date.now(); + entitiesUpdatedAt = lastEntities; + } + let chunkEstimate: number | null = 0; + for (const player of players) { + const distance = player.clientSystemInfo?.maxRenderDistance; + if (typeof distance !== "number") { + chunkEstimate = null; + break; + } + chunkEstimate += (distance + 2) ** 2; + } + state.snapshot = { + bootId, + tps: sampler.current(), + onlineCount: players.length, + entities, + entitiesUpdatedAt, + chunkEstimate, + }; + busy = true; + void HttpDB.typedRequest(SafeHttpMethod.Post, "/api/sfmc/metrics/live", { + ...state.snapshot, + players: players.map((player) => player.name), + day: world.getDay(), + difficulty: String(world.getDifficulty()), + }) + .then((result) => { + if (!result.ok && !disposed) warn(); + }) + .catch(() => { + if (!disposed) warn(); + }) + .finally(() => { + busy = false; + }); + } catch { + /* 世界关闭期间不继续读取原生对象。 */ + } + }; + const tick = system.runInterval(() => sampler.push(Date.now()), 1); + const reportTick = system.runInterval(report, 100); + report(); + state.stop = () => { + disposed = true; + system.clearRun(tick); + system.clearRun(reportTick); + sampler.reset(); + delete state.snapshot; + delete state.stop; + }; + return state.stop; +} diff --git a/modules/sdk/@sfmc-sdk/src/sapi/runtime/tick-sampler.ts b/modules/sdk/@sfmc-sdk/src/sapi/runtime/tick-sampler.ts new file mode 100644 index 00000000..fd40b103 --- /dev/null +++ b/modules/sdk/@sfmc-sdk/src/sapi/runtime/tick-sampler.ts @@ -0,0 +1,24 @@ +/** 环形墙钟采样;未获得有效时间跨度时返回未知,避免冷启动伪报 20 TPS。 */ +export class TickSampler { + private samples: number[] = []; + private cursor = 0; + push(time: number): void { + const last = this.samples[(this.cursor + this.samples.length - 1) % this.samples.length]; + if (!Number.isFinite(time) || (last !== undefined && time <= last)) this.reset(); + if (this.samples.length < 100) this.samples.push(time); + else this.samples[this.cursor] = time; + this.cursor = (this.cursor + 1) % 100; + } + current(): number | null { + const count = this.samples.length; + if (count < 2) return null; + const first = this.samples[count < 100 ? 0 : this.cursor]!; + const last = this.samples[(this.cursor + count - 1) % count]!; + const span = last - first; + return span > 0 ? Math.round(Math.min(20, ((count - 1) * 1000) / span) * 100) / 100 : null; + } + reset(): void { + this.samples = []; + this.cursor = 0; + } +} diff --git a/package.json b/package.json index d8ddad5f..878ca926 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "changeset": "changeset", "ci-release-packages": "node packages/tools/run-release.mjs --ci", "docs": "node packages/tools/docs.mjs", + "desktop:dev": "pnpm --filter @sfmc-bds/desktop dev", + "desktop:build": "pnpm --filter @sfmc-bds/sfmc... build && pnpm --filter @sfmc-bds/desktop build", + "desktop:package": "pnpm --filter @sfmc-bds/desktop package", "lint": "pnpm --filter @sfmc-bds/eslint-plugin run build && eslint .", "start": "node packages/cli/dist/main.js", "syncpack:fix": "node packages/tools/syncpack-fix.mjs", diff --git a/packages/bds-tools/src/check-update.ts b/packages/bds-tools/src/check-update.ts index a491ec5e..9c79f3ec 100644 --- a/packages/bds-tools/src/check-update.ts +++ b/packages/bds-tools/src/check-update.ts @@ -81,7 +81,7 @@ function buildContext(): UpdateContext { backupDir: backup_dir, preserve, autoRestart: !args["no-start"], - qqNotify: !!cfg.qq_notify, + qqNotify: !!cfg.qq_notify && !args["no-notify"], }; } diff --git a/packages/bds-tools/src/process-probe.ts b/packages/bds-tools/src/process-probe.ts index 3df3e26a..00d8efb0 100644 --- a/packages/bds-tools/src/process-probe.ts +++ b/packages/bds-tools/src/process-probe.ts @@ -4,7 +4,7 @@ * 供 bds-manager / sfmc 共用,避免重复 tasklist / taskkill 逻辑。 */ -import { exec, execSync } from "node:child_process"; +import { exec } from "node:child_process"; import { promisify } from "node:util"; import fs from "node:fs"; import path from "node:path"; @@ -89,14 +89,10 @@ export async function isProcessAlive(pid: number): Promise { if (!pid) return false; if (aliveOverride) return aliveOverride(pid); try { - if (process.platform === "win32") { - const { stdout } = await execImpl(`tasklist /fi "PID eq ${pid}" /nh`, { windowsHide: true }); - return stdout.includes(String(pid)); - } process.kill(pid, 0); return true; - } catch { - return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; } } @@ -105,17 +101,10 @@ export function isProcessAliveSync(pid: number): boolean { if (!pid) return false; if (aliveOverride) return aliveOverride(pid); try { - if (process.platform === "win32") { - const stdout = execSync(`tasklist /fi "PID eq ${pid}" /nh`, { - windowsHide: true, - encoding: "utf8", - }); - return String(stdout).includes(String(pid)); - } process.kill(pid, 0); return true; - } catch { - return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; } } diff --git a/packages/cli/package.json b/packages/cli/package.json index c99512cc..92d990c8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -57,10 +57,14 @@ "dependencies": { "@clack/prompts": "^1.7.0", "@sfmc-bds/bds-tools": "workspace:*", + "@sfmc-bds/devkit": "workspace:*", "@sfmc-bds/sdk": "workspace:*", "chalk": "^6.0.0", "cross-spawn": "7.0.6", - "esbuild": "^0.28.1" + "esbuild": "^0.28.1", + "@sfmc-bds/management": "workspace:*", + "ajv": "8.20.0", + "jsonc-parser": "3.3.1" }, "devDependencies": { "@sfmc-bds/tools": "workspace:*" diff --git a/packages/cli/scripts/module-install/.sfmc-registry-cache.json b/packages/cli/scripts/module-install/.sfmc-registry-cache.json new file mode 100644 index 00000000..7e4b94ca --- /dev/null +++ b/packages/cli/scripts/module-install/.sfmc-registry-cache.json @@ -0,0 +1,95 @@ +{ + "fetchedAt": 1790867366873, + "index": { + "activity-log": { + "npm": "@sfmc-bds/module-activity-log", + "version": "0.2.2", + "sdk": ">=0.2.0" + }, + "afk": { + "npm": "@sfmc-bds/module-afk", + "version": "0.2.1", + "sdk": ">=0.2.0" + }, + "area": { + "npm": "@sfmc-bds/module-area", + "version": "0.2.1", + "sdk": ">=0.2.0" + }, + "chat": { + "npm": "@sfmc-bds/module-chat", + "version": "0.2.2", + "sdk": ">=0.2.2" + }, + "chat-sounds": { + "npm": "@sfmc-bds/module-chat-sounds", + "version": "0.2.1", + "sdk": ">=0.2.0" + }, + "clean": { + "npm": "@sfmc-bds/module-clean", + "version": "0.2.1", + "sdk": ">=0.2.0" + }, + "coop": { + "npm": "@sfmc-bds/module-coop", + "version": "0.2.2", + "sdk": ">=0.2.0" + }, + "data-backup": { + "npm": "@sfmc-bds/module-data-backup", + "version": "0.2.2", + "sdk": ">=0.2.0" + }, + "economy": { + "npm": "@sfmc-bds/module-economy", + "version": "0.2.1", + "sdk": ">=0.2.0" + }, + "fly-area": { + "npm": "@sfmc-bds/module-fly-area", + "version": "0.2.1", + "sdk": ">=0.2.0" + }, + "gamemode-area": { + "npm": "@sfmc-bds/module-gamemode-area", + "version": "0.2.1", + "sdk": ">=0.2.0" + }, + "inventory-switcher": { + "npm": "@sfmc-bds/module-inventory-switcher", + "version": "0.2.1", + "sdk": ">=0.2.0" + }, + "land": { + "npm": "@sfmc-bds/module-land", + "version": "0.2.2", + "sdk": ">=0.2.0" + }, + "monitor": { + "npm": "@sfmc-bds/module-monitor", + "version": "0.2.1", + "sdk": ">=0.2.0" + }, + "online-time": { + "npm": "@sfmc-bds/module-online-time", + "version": "0.2.2", + "sdk": ">=0.2.0" + }, + "peace-area": { + "npm": "@sfmc-bds/module-peace-area", + "version": "0.2.1", + "sdk": ">=0.2.0" + }, + "qa": { + "npm": "@sfmc-bds/module-qa", + "version": "0.2.1", + "sdk": ">=0.2.0" + }, + "spawn-protect": { + "npm": "@sfmc-bds/module-spawn-protect", + "version": "0.2.1", + "sdk": ">=0.2.0" + } + } +} \ No newline at end of file diff --git a/packages/cli/scripts/module-install/fetch-module.mjs b/packages/cli/scripts/module-install/fetch-module.mjs index 0f19a2fa..0e235667 100644 --- a/packages/cli/scripts/module-install/fetch-module.mjs +++ b/packages/cli/scripts/module-install/fetch-module.mjs @@ -28,6 +28,7 @@ */ import { extractZipFileToDir } from "@sfmc-bds/bds-tools/zipx"; +import { isRetiredPlatformModule } from "@sfmc-bds/sdk/contracts"; import { createHash } from "node:crypto"; import fs, { createReadStream } from "node:fs"; import fsp from "node:fs/promises"; @@ -260,6 +261,7 @@ function afterInstall(folder, opts = {}) { const moduleRoot = path.join(TARGET, folder); const preview = loadPackageCatalogEntry(folder); if (!preview) throw new Error(`packages/${folder}: 无法读取 sapi/manifest.json`); + if (isRetiredPlatformModule(preview.id)) throw new Error("monitor 已收编至平台,无需安装;请更新平台行为包。"); const seeded = seedModuleConfig({ moduleRoot, projectRoot: ROOT, @@ -779,8 +781,9 @@ async function installWithPnpm(projectDir, packageSpec) { const spawnChild = (await import("cross-spawn")).default; await new Promise((resolve, reject) => { const proc = spawnChild( - "pnpm", + process.env.SFMC_PNPM_ENTRY ? (process.env.SFMC_NODE_BINARY || process.execPath) : "pnpm", [ + ...(process.env.SFMC_PNPM_ENTRY ? [process.env.SFMC_PNPM_ENTRY] : []), "--dir", projectDir, "add", @@ -893,6 +896,7 @@ async function copyDir(src, dst) { * @param {{ from: any; sha256?: null; link: any; }} flags */ async function installOne(id, flags) { + if (isRetiredPlatformModule(id)) throw new Error("monitor 已收编至平台,无需安装;请更新平台行为包。"); let from = flags.from; if (!from) { from = await defaultSourceFor(id); diff --git a/packages/cli/scripts/module-install/lib/packages.mjs b/packages/cli/scripts/module-install/lib/packages.mjs index 77c27840..5778c11a 100644 --- a/packages/cli/scripts/module-install/lib/packages.mjs +++ b/packages/cli/scripts/module-install/lib/packages.mjs @@ -82,18 +82,15 @@ export function projectCatalogEntry(folder, manifest) { if (!id) throw new Error(`packages/${folder}: manifest.id 缺失`); if (!configKey) throw new Error(`packages/${folder}: manifest.configKey 缺失`); - /* 缺省:默认启用、允许禁用;不可禁用须在 manifest 显式 canDisable:false */ - const enabledByDefault = typeof manifest.enabledByDefault === "boolean" ? manifest.enabledByDefault : true; - const canDisable = typeof manifest.canDisable === "boolean" ? manifest.canDisable : true; - /** @type {CatalogEntry} */ const entry = { id, configKey, name: String(manifest.name || configKey), description: String(manifest.description || ""), - enabledByDefault, - canDisable, + /* manifest 不再声明启停策略;安装默认启用,允许服主禁用。 */ + enabledByDefault: true, + canDisable: true, requires: Array.isArray(manifest.requires) ? manifest.requires.map(String) : [], entry: { kind: "sapi", diff --git a/packages/cli/src/command-surface.ts b/packages/cli/src/command-surface.ts index 38513246..8b11e956 100644 --- a/packages/cli/src/command-surface.ts +++ b/packages/cli/src/command-surface.ts @@ -127,17 +127,13 @@ export function findTopLevelSpec(cmd: string | undefined): CommandSpec | undefin /** 查找 module 子命令 spec。 */ export function findModuleSubSpec(sub: string | undefined): CommandSpec | undefined { if (!sub) return undefined; - return COMMAND_SPECS.find( - (s) => s.name === "module" && (s.sub === sub || (s.aliases ?? []).includes(sub)) - ); + return COMMAND_SPECS.find((s) => s.name === "module" && (s.sub === sub || (s.aliases ?? []).includes(sub))); } /** 查找 packs 子命令 spec。 */ export function findPacksSubSpec(sub: string | undefined): CommandSpec | undefined { if (!sub) return undefined; - return COMMAND_SPECS.find( - (s) => s.name === "packs" && (s.sub === sub || (s.aliases ?? []).includes(sub)) - ); + return COMMAND_SPECS.find((s) => s.name === "packs" && (s.sub === sub || (s.aliases ?? []).includes(sub))); } /** 当前 mode 下可见的 module 子命令名(规范名,不含 remove 别名)。 */ @@ -196,10 +192,12 @@ const TOP_DESC: Record = { install: "help.module.install", uninstall: "help.module.uninstall", search: "help.module.search", + submit: "help.module.submit", verify: "help.module.verify", }; const MODULE_DESC: Record = { + submit: "help.module.submit", list: "help.module.list", info: "help.module.info", build: "help.module.build", @@ -258,7 +256,10 @@ function moduleChildNodes(mode: CommandMode): PaletteNode[] { * 「直接运行」项:token 为空,回车提交已经写好的父命令。 * 用在无参本身就是合法命令、同时还要展开可选开关的节点上。 */ -function asIsNode(descKey: string, labelKey: "palette.asIs" | "palette.fixOnly" | "palette.localeCurrent" = "palette.asIs"): PaletteNode { +function asIsNode( + descKey: string, + labelKey: "palette.asIs" | "palette.fixOnly" | "palette.localeCurrent" = "palette.asIs" +): PaletteNode { return { label: t(labelKey), token: "", descKey }; } @@ -515,6 +516,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ aliases: ["remove"], }, { id: "module.search", name: "module", sub: "search", channel: "both" }, + { id: "module.submit", name: "module", sub: "submit", channel: "both", accent: "dev" }, { id: "module.verify", name: "module", sub: "verify", channel: "both" }, { id: "module.enable", name: "module", sub: "enable", channel: "both" }, { id: "module.disable", name: "module", sub: "disable", channel: "both" }, diff --git a/packages/cli/src/daemon/client.ts b/packages/cli/src/daemon/client.ts index b877859e..9e98f8c5 100644 --- a/packages/cli/src/daemon/client.ts +++ b/packages/cli/src/daemon/client.ts @@ -10,6 +10,7 @@ import { logsDir } from "@sfmc-bds/sdk/node/config"; import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import fs from "node:fs"; +import path from "node:path"; import net from "node:net"; import process from "node:process"; import { EventEmitter } from "node:events"; @@ -33,6 +34,15 @@ import type { } from "./protocol.js"; import { isLogPayload } from "./protocol.js"; import { isDaemonServer } from "./role.js"; +import { fileURLToPath } from "node:url"; +import type { ManagementEvent } from "@sfmc-bds/management"; +import { maintenanceToken } from "@sfmc-bds/management/node"; + +export function onDaemonManagementEvent(callback: (event: ManagementEvent) => void): () => void { + remoteStateBus.on("management", callback); + return () => { remoteStateBus.off("management", callback); }; +} +export function disconnectDaemonClient(): void { shared?.disconnect(); shared = null; } /** 单次 RPC 默认超时(毫秒) */ const CALL_TIMEOUT_MS = 120_000; @@ -108,6 +118,7 @@ class DaemonConnection { } private handleEvent(ev: DaemonEvent): void { + if (ev.event === "management") { remoteStateBus.emit("management", ev.payload); return; } if (ev.event === "log" && isLogPayload(ev.payload)) { ingestRemoteLog(ev.payload.text, ev.payload.source, ev.payload.level); return; @@ -197,7 +208,6 @@ async function tryConnectExisting(): Promise { const meta = readDaemonMeta(); if (!meta) return null; if (!(await isProcessAlive(meta.pid))) { - clearDaemonMeta(); return null; } try { @@ -215,11 +225,16 @@ async function tryConnectExisting(): Promise { * 入口脚本复用当前 CLI 的 argv[1](即 sfmc 主入口)。 */ function spawnDaemonProcess(): void { - const entry = process.argv[1]; + const lockFile = path.join(ROOT, ".sfmc", "maintenance.lock"); + if (fs.existsSync(lockFile)) { + const owner = JSON.parse(fs.readFileSync(lockFile, "utf8")) as { nonce: string }; + if ((maintenanceToken(ROOT) ?? process.env.SFMC_MAINTENANCE_TOKEN) !== owner.nonce) throw new Error("实例正在维护;后台切换完成前不会创建另一个守护进程"); + } + const entry = process.env.SFMC_DAEMON_ENTRY ?? fileURLToPath(new URL("../main.js", import.meta.url)); if (!entry) throw new Error("cannot resolve sfmc entry for daemon spawn"); fs.mkdirSync(logsDir(ROOT), { recursive: true }); const logFd = fs.openSync(daemonLogPath(), "a"); - const child = spawn(process.execPath, [entry, "--daemon"], { + const child = spawn(process.env.SFMC_NODE_BINARY ?? process.execPath, [entry, "--daemon"], { detached: true, windowsHide: true, stdio: ["ignore", logFd, logFd], @@ -265,7 +280,6 @@ export async function ensureDaemon(): Promise { throw new Error(`daemon pid ${existing.pid} is alive but pipe is unreachable`); } - clearDaemonMeta(); spawnDaemonProcess(); const deadline = Date.now() + READY_TIMEOUT_MS; while (Date.now() < deadline) { @@ -286,7 +300,8 @@ export async function ensureDaemon(): Promise { */ export async function callDaemon(method: DaemonMethod, params?: DaemonParams): Promise { const conn = await ensureDaemon(); - return conn.call(method, params); + const token = maintenanceToken(ROOT) ?? process.env.SFMC_MAINTENANCE_TOKEN; + return conn.call(method, token ? { ...params, maintenanceToken: token } : params); } /** @@ -307,12 +322,13 @@ export async function ensureDaemonSubscription(): Promise<() => void> { export async function shutdownDaemon(): Promise { try { const conn = await ensureDaemon(); - await conn.call("shutdown"); + const token = maintenanceToken(ROOT) ?? process.env.SFMC_MAINTENANCE_TOKEN; + await conn.call("shutdown", token ? { maintenanceToken: token } : undefined); conn.disconnect(); } catch (e) { - /* 守护进程可能已不在 */ - clearDaemonMeta(); - return e instanceof Error ? e.message : String(e); + const meta = readDaemonMeta(); + if (!meta || !await isProcessAlive(meta.pid)) { clearDaemonMeta(); return "守护进程已停止"; } + throw e; } /* 短暂等待 meta 清理 */ const deadline = Date.now() + 5000; @@ -320,6 +336,8 @@ export async function shutdownDaemon(): Promise { if (!readDaemonMeta()) break; await new Promise((r) => setTimeout(r, 100)); } + const remaining = readDaemonMeta(); + if (remaining && await isProcessAlive(remaining.pid)) throw new Error("守护进程尚未退出,保留连接信息"); clearDaemonMeta(); return ""; } diff --git a/packages/cli/src/daemon/protocol.ts b/packages/cli/src/daemon/protocol.ts index 36fb8d0e..7fd80f41 100644 --- a/packages/cli/src/daemon/protocol.ts +++ b/packages/cli/src/daemon/protocol.ts @@ -7,9 +7,12 @@ import type { UnifiedLog, LogLevel, LogSource } from "../logs.js"; import type { ServiceName, ServiceStateEvent, ServiceStatus } from "../services.js"; +import type { ManagementRequest, ManagementResponse, ManagementEvent } from "@sfmc-bds/management"; /** 协议方法名(固定集合,OCP:新增能力加此处与 server 分发) */ export type DaemonMethod = + | "management" + | "maintenanceStop" | "ping" | "status" | "start" @@ -32,6 +35,8 @@ export interface DaemonRequest { /** 各方法参数(按 method 选用) */ export type DaemonParams = { + request?: ManagementRequest; + maintenanceToken?: string; name?: string; message?: string; args?: string[]; @@ -49,8 +54,8 @@ export interface DaemonResponse { /** 服务端推送事件(subscribe 之后) */ export interface DaemonEvent { type: "event"; - event: "log" | "state"; - payload: DaemonLogPayload | ServiceStateEvent; + event: "log" | "state" | "management"; + payload: DaemonLogPayload | ServiceStateEvent | ManagementEvent; } /** 日志事件载荷(与 UnifiedLog 对齐,便于 CLI 侧 pushLog) */ @@ -63,6 +68,7 @@ export interface DaemonLogPayload { /** status 等方法的结构化结果 */ export type DaemonResult = + | { kind: "management"; response: ManagementResponse } | { kind: "pong" } | { kind: "text"; text: string } | { kind: "status"; text: string; rows: ServiceStatus[] } diff --git a/packages/cli/src/daemon/server.ts b/packages/cli/src/daemon/server.ts index 131b4a9c..ea0be0f2 100644 --- a/packages/cli/src/daemon/server.ts +++ b/packages/cli/src/daemon/server.ts @@ -19,6 +19,7 @@ import { } from "../commands.js"; import { onLog } from "../logs.js"; import { onServiceStateChange, queryServicesRuntimeLocal, stopAll } from "../services.js"; +import { startAll } from "../services.js"; import { clearDaemonMeta, createDaemonToken, @@ -29,6 +30,17 @@ import { import type { DaemonEvent, DaemonRequest, DaemonResponse, DaemonResult } from "./protocol.js"; import { toLogPayload } from "./protocol.js"; import { markDaemonServer } from "./role.js"; +import { dispatchManagement, onManagementEvent } from "../management/server.js"; +import { toManagementError } from "@sfmc-bds/management"; +import { withMaintenanceLock } from "@sfmc-bds/management/node"; +import { ROOT } from "../runtime.js"; +import fs from "node:fs"; +import path from "node:path"; +import { stopForMaintenance } from "../management/maintenance.js"; +import { START_ORDER } from "../services.js"; +import { readDaemonMeta } from "./paths.js"; +import { isProcessAlive } from "@sfmc-bds/bds-tools/process-probe"; +import { randomUUID } from "node:crypto"; /** 当前已订阅事件推送的 socket 集合 */ const subscribers = new Set(); @@ -47,6 +59,7 @@ function broadcast(event: DaemonEvent): void { subscribers.delete(sock); continue; } + if (sock.writableLength > 4 * 1024 * 1024) { sock.destroy(); subscribers.delete(sock); continue; } try { sock.write(line); } catch { @@ -68,7 +81,27 @@ function writeRes(sock: net.Socket, res: DaemonResponse): void { * 分发单条 RPC 到现有 cmd* / services API(本进程已是 daemon,cmd* 走本地实现)。 */ async function dispatch(req: DaemonRequest): Promise { + const mutable = ["start", "stop", "restart", "startAll", "stopAll", "send", "update", "shutdown", "maintenanceStop"].includes(req.method); + if (mutable) { + const owner = fs.existsSync(path.join(ROOT, ".sfmc", "maintenance.lock")) ? JSON.parse(fs.readFileSync(path.join(ROOT, ".sfmc", "maintenance.lock"), "utf8")) as { nonce?: string } : null; + if (!req.params?.maintenanceToken || req.params.maintenanceToken !== owner?.nonce) { + return withMaintenanceLock(ROOT, () => dispatchUnlocked(req)); + } + } + return dispatchUnlocked(req); +} +async function dispatchUnlocked(req: DaemonRequest): Promise { switch (req.method) { + case "maintenanceStop": { + await stopForMaintenance([...START_ORDER], () => {}); + return { kind: "status", text: "", rows: await queryServicesRuntimeLocal() }; + } + case "management": { + const request = req.params?.request; + if (!request || typeof request.id !== "string" || typeof request.method !== "string") throw new Error("invalid management request"); + try { return { kind: "management", response: { type: "res", id: request.id, ok: true, result: await dispatchManagement(request) } }; } + catch (error) { return { kind: "management", response: { type: "res", id: request.id, ok: false, error: toManagementError(error) } }; } + } case "ping": return { kind: "pong" }; case "status": { @@ -118,7 +151,8 @@ async function dispatch(req: DaemonRequest): Promise { case "shutdown": { if (!shuttingDown) { shuttingDown = true; - await stopAll(); + try { await stopForMaintenance([...START_ORDER], () => {}); } + catch (error) { shuttingDown = false; throw error; } setTimeout(() => { clearDaemonMeta(); process.exit(0); @@ -177,8 +211,20 @@ async function handleLine(sock: net.Socket, line: string, expectedToken: string) * 本函数在成功 listen 后永不返回(进程常驻)。 */ export async function runDaemonServer(): Promise { + const startLock = path.join(ROOT, ".sfmc", "daemon-start.lock"); + fs.mkdirSync(path.dirname(startLock), { recursive: true }); + const nonce = randomUUID(); + let handle: number; + try { handle = fs.openSync(startLock, "wx", 0o600); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error("守护进程正在启动或启动锁遗留;不会创建第二套后台,请检查 daemon-start.lock"); throw error; } + fs.writeFileSync(handle, JSON.stringify({ pid: process.pid, nonce })); fs.closeSync(handle); + const releaseStartup = () => { try { const owner = JSON.parse(fs.readFileSync(startLock, "utf8")) as { nonce: string }; if (owner.nonce === nonce) fs.unlinkSync(startLock); } catch { /* 已释放 */ } }; + process.once("exit", releaseStartup); + const previous = readDaemonMeta(); + if (previous && await isProcessAlive(previous.pid)) { releaseStartup(); process.exit(0); } + if (previous) clearDaemonMeta(); markDaemonServer(); - clearDaemonMeta(); + const unsubManagement = onManagementEvent(event => broadcast({ type: "event", event: "management", payload: event })); const token = createDaemonToken(); const pipe = daemonPipePath(); @@ -201,6 +247,7 @@ export async function runDaemonServer(): Promise { sock.setEncoding("utf8"); sock.on("data", (chunk: string) => { buf += chunk; + if (Buffer.byteLength(buf) > 8 * 1024 * 1024) { sock.destroy(); return; } let idx: number; while ((idx = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, idx).trim(); @@ -233,8 +280,11 @@ export async function runDaemonServer(): Promise { }); writeDaemonMeta(meta); + releaseStartup(); + if (process.argv.includes("--autostart") && !fs.existsSync(path.join(ROOT, ".sfmc", "maintenance.lock"))) await startAll(); const cleanup = (): void => { + unsubManagement(); unsubLog(); unsubState(); try { diff --git a/packages/cli/src/i18n/locales/en.ts b/packages/cli/src/i18n/locales/en.ts index 7bfa582b..1924f7ba 100644 --- a/packages/cli/src/i18n/locales/en.ts +++ b/packages/cli/src/i18n/locales/en.ts @@ -94,6 +94,7 @@ export const en = { "help.section.module": "─── Module Management ───", "help.module.list": "List installed modules", "help.module.search": "Search registry / show module info", + "help.module.submit": "Preview or submit a module registry PR", "help.module.install": "Install a module", "help.module.uninstall": "Remove installed module", "help.module.verify": "Verify SHA-256 integrity", diff --git a/packages/cli/src/i18n/locales/zh-CN.ts b/packages/cli/src/i18n/locales/zh-CN.ts index d24598fe..317de918 100644 --- a/packages/cli/src/i18n/locales/zh-CN.ts +++ b/packages/cli/src/i18n/locales/zh-CN.ts @@ -91,6 +91,7 @@ export const zhCN: Record = { "help.section.module": " 模块管理 ──────", "help.module.list": "列出已安装模块", "help.module.search": "在线搜索模块", + "help.module.submit": "预览或提交模块到官方索引仓", "help.module.install": "安装模块", "help.module.uninstall": "卸载已安装模块", "help.module.verify": "校验模块 SHA-256 完整性", @@ -110,8 +111,7 @@ export const zhCN: Record = { "help.packs.doctor.fix": "修复接线并补齐缺失的测试版 API", "help.packs.doctor.allExperiments": "开启全部已知实验性玩法", "help.packs.doctor.experiments": "仅查看实验性开关状态", - "help.packs.doctor.experimentsList": - "按别名开启,逗号分隔:beta, upcoming, cameras, voxel, villager, drop3, edu", + "help.packs.doctor.experimentsList": "按别名开启,逗号分隔:beta, upcoming, cameras, voxel, villager, drop3, edu", "help.packs.path": "显示资源包路径", "help.section.general": " 通用 ──────", "help.init": "初始化向导", diff --git a/packages/cli/src/logs.ts b/packages/cli/src/logs.ts index 85d5359a..5d3dce4f 100644 --- a/packages/cli/src/logs.ts +++ b/packages/cli/src/logs.ts @@ -232,7 +232,17 @@ export function readDiskLogs(opts: ReadDiskLogsOpts = {}): UnifiedLog[] { const fp = path.join(dir, name.endsWith(".log") ? name : `${name}.log`); let text: string; try { - text = fs.readFileSync(fp, "utf8"); + if (opts.limit) { + const fd = fs.openSync(fp, "r"); + try { + const size = fs.fstatSync(fd).size; + const start = Math.max(0, size - 4 * 1024 * 1024); + const bytes = Buffer.alloc(size - start); + fs.readSync(fd, bytes, 0, bytes.length, start); + text = bytes.toString("utf8"); + if (start > 0) text = text.slice(text.indexOf("\n") + 1); + } finally { fs.closeSync(fd); } + } else text = fs.readFileSync(fp, "utf8"); } catch { continue; } diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 6dba9bed..e4fd18dc 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -1,203 +1,19 @@ #!/usr/bin/env node -import process from "node:process"; -import { mapPacksSubAlias, parseGlobalArgv } from "./argv-parse.js"; -import { cmdRestart, cmdStart, cmdStartAll, cmdStatus, cmdStop, cmdStopAll, cmdUpdate } from "./commands.js"; -import { gateModuleSub, gatePacksSub, gateTopLevel } from "./cli-gate.js"; -import { resolveModuleTopShorthand } from "./command-surface.js"; -import { cmdDebug } from "./debug-command.js"; -import { initLocale, stripLangArgs, t } from "./i18n/index.js"; -import { cmdLocale } from "./locale-command.js"; -import { dispatchModuleCommand, isModuleCommand, scanAndWarnUnknown } from "./module-commands.js"; -import { getHelp, playWelcomeAnimation, startRepl } from "./repl.js"; -import { ROOT } from "./runtime.js"; -import { c } from "./theme.js"; -import { dispatchPacksCommand, isPacksCommand } from "./world-packs.js"; - -const MODE = "argv" as const; - -async function printVersion(): Promise { - await playWelcomeAnimation(); -} - -function printUsage(): void { - console.log(`${getHelp("argv")}`); -} - -function deny(msg: string): never { - console.log(msg); - process.exit(1); -} - -async function main(): Promise { - const stripped = stripLangArgs(process.argv.slice(2)); - initLocale({ root: ROOT, flag: stripped.lang }); - let args = stripped.args; - - /* 内部入口:后台守护进程(不解析用户命令) */ - if (args[0] === "--daemon") { - const { runDaemonServer } = await import("./daemon/server.js"); - await runDaemonServer(); - return; - } - - const { packsMode, args: parsedArgs } = parseGlobalArgv(args); - args = parsedArgs; - - if (args.length === 0 && !packsMode) { - const { isRuntimeInitialized } = await import("./runtime.js"); - if (!isRuntimeInitialized()) { - const { runWizard } = await import("./wizard.js"); - await runWizard(); - const { refreshServices } = await import("./services.js"); - refreshServices(); - } - const warn = await scanAndWarnUnknown(); - if (warn) console.log(warn); - await startRepl(); - return; - } - - if (packsMode) { - const [subRaw, ...subRest] = args; - const sub = mapPacksSubAlias(subRaw); - const g = gatePacksSub(sub, MODE); - if (g) deny(g); - console.log(await dispatchPacksCommand(sub, subRest)); - process.exit(0); - } - - const [cmd, ...rest] = args; - - if (cmd && !["--help", "-h", "--version", "-v"].includes(cmd)) { - const topGate = gateTopLevel(cmd, MODE); - if (topGate) deny(topGate); +/** 作者命令先分流,避免仅提交索引时初始化服务与运行目录配置。 */ +import { stripLangArgs } from "./i18n/index.js"; + +const args = stripLangArgs(process.argv.slice(2)).args; +if (args[0] === "manage" && args[1] === "--stdio") { + const { runManagementStdio } = await import("./management/stdio.js"); + await runManagementStdio(); +} else if ((args[0] === "mod" || args[0] === "module") && args[1] === "submit") { + try { + const { runRegistrySubmitCommand } = await import("@sfmc-bds/devkit"); + console.log(await runRegistrySubmitCommand(args.slice(2))); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; } - - const moduleSub = resolveModuleTopShorthand(cmd); - if (moduleSub) { - const g = gateModuleSub(moduleSub, MODE); - if (g) deny(g); - console.log(await dispatchModuleCommand(moduleSub, rest)); - process.exit(0); - } - - switch (cmd) { - case "--help": - case "-h": - case "help": - printUsage(); - break; - case "--version": - case "-v": - await printVersion(); - break; - case "locale": - case "lang": - console.log(cmdLocale(rest)); - break; - case "debug": - console.log(await cmdDebug(rest)); - break; - case "ui": { - const { cmdUi } = await import("./ui-command.js"); - console.log(await cmdUi(rest, { block: true })); - break; - } - case "daemon": { - const sub = rest[0]; - if (sub === "stop") { - const { shutdownDaemon } = await import("./daemon/client.js"); - const err = await shutdownDaemon(); - console.log(err ? c.red(err) : c.dim(t("daemon.stopped"))); - } else if (sub === "status" || !sub) { - const { readDaemonMeta } = await import("./daemon/paths.js"); - const { isProcessAlive } = await import("@sfmc-bds/bds-tools/process-probe"); - const meta = readDaemonMeta(); - if (!meta || !(await isProcessAlive(meta.pid))) { - console.log(c.dim(t("daemon.notRunning"))); - } else { - console.log(c.green(t("daemon.running", { pid: String(meta.pid) }))); - } - } else { - console.log(c.yellow(t("daemon.usage"))); - } - break; - } - case "status": - console.log(await cmdStatus()); - break; - case "logs": - case "log": - /* channel=repl:正常由 gateTopLevel 拦截;此处兜底 */ - deny(gateTopLevel("logs", MODE) ?? c.yellow(t("cli.replOnly", { cmd: "logs" }))); - break; - case "start": - if (rest[0] === "-all" || rest[0] === "all" || rest[0] === "--all") { - console.log(await cmdStartAll()); - } else if (rest[0]) { - console.log(await cmdStart(rest[0])); - } else { - console.log(c.yellow(t("svc.start.usage"))); - } - break; - case "stop": - if (rest[0] === "-all" || rest[0] === "all" || rest[0] === "--all") { - console.log(await cmdStopAll()); - } else if (rest[0]) { - console.log(await cmdStop(rest[0])); - } else { - console.log(c.yellow(t("svc.stop.usage"))); - } - break; - case "restart": - if (rest[0] === "-all" || rest[0] === "all" || rest[0] === "--all") { - await cmdStopAll(); - console.log(await cmdStartAll()); - } else if (rest[0]) { - console.log(await cmdRestart(rest[0])); - } else { - console.log(c.yellow(t("svc.restart.usage"))); - } - break; - case "update": - console.log(await cmdUpdate(rest)); - break; - case "packs": - case "addon": { - const [sub, ...subRest] = rest; - const g = gatePacksSub(sub, MODE); - if (g) deny(g); - console.log(await dispatchPacksCommand(sub, subRest)); - break; - } - case "init": { - const { runWizard } = await import("./wizard.js"); - await runWizard(); - break; - } - default: - if (isModuleCommand(cmd)) { - const [sub, ...subRest] = rest; - const g = gateModuleSub(sub, MODE); - if (g) deny(g); - console.log(await dispatchModuleCommand(sub, subRest)); - break; - } - if (isPacksCommand(cmd)) { - const [sub, ...subRest] = rest; - const g = gatePacksSub(sub, MODE); - if (g) deny(g); - console.log(await dispatchPacksCommand(sub, subRest)); - break; - } - console.log(c.red(t("common.unknownCommand", { cmd: cmd ?? "" }))); - printUsage(); - process.exit(1); - } - process.exit(0); +} else { + await import("./runtime-main.js"); } - -main().catch((err) => { - console.error(c.red(err?.message ? t("common.error", { message: err.message }) : t("common.fatal"))); - process.exit(1); -}); diff --git a/packages/cli/src/management/config.ts b/packages/cli/src/management/config.ts new file mode 100644 index 00000000..e27e3c96 --- /dev/null +++ b/packages/cli/src/management/config.ts @@ -0,0 +1,76 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +import Ajv2020 from "ajv/dist/2020.js"; +import { parse, type ParseError } from "jsonc-parser"; +import type { ConfigDocument } from "@sfmc-bds/management"; +import { resolveSdkPackageRoot, ROOT } from "../runtime.js"; +import { resolveBdsContext } from "../pack-lifecycle.js"; + +const CORE_FILES = ["runtime.json", "db_config.json", "qq_config.json", "bds_updater.json", "permissions.json", "log_filter.json", "module_update.json"]; +const ajv = new Ajv2020.default({ allErrors: true, strict: false }); +const validators = new Map>(); +export function configFiles(): Map { + const files = new Map(CORE_FILES.map(name => [`core/${name}`, path.join(ROOT, "configs", name)])); + files.set("bds/server.properties", path.join(resolveBdsContext().bdsRoot, "server.properties")); + const modules = path.join(ROOT, "modules", "packages"); + if (fs.existsSync(modules)) for (const entry of fs.readdirSync(modules, { withFileTypes: true })) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + const configDir = path.join(modules, entry.name, "configs"); + if (!fs.existsSync(configDir)) continue; + for (const filename of fs.readdirSync(configDir)) if (/^[\w.-]+\.jsonc?$/.test(filename)) files.set(`module/${entry.name}/${filename}`, path.join(configDir, filename)); + } + return files; +} +export function configPath(key: string): string { + const file = configFiles().get(key); + if (!file) throw Object.assign(new Error("配置不在允许编辑的列表中"), { code: "validation" }); + if (fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) throw new Error("不能通过配置文件链接写入其他文件"); + return file; +} +export function revision(text: string) { return createHash("sha256").update(text).digest("hex"); } +export function readDocument(key: string): ConfigDocument { + const file = configPath(key); + const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : ""; + let schema: object | undefined; + if (key.startsWith("core/")) { + const name = path.basename(file, ".json"); + const schemaFile = path.join(resolveSdkPackageRoot(), "schemas", `${name}.schema.json`); + if (fs.existsSync(schemaFile)) schema = JSON.parse(fs.readFileSync(schemaFile, "utf8")) as object; + } else if (key.startsWith("module/")) { + const schemaFile = path.join(path.dirname(file), `${path.basename(file).replace(/\.jsonc?$/, "")}.schema.json`); + if (fs.existsSync(schemaFile)) schema = JSON.parse(fs.readFileSync(schemaFile, "utf8")) as object; + } + return { key, text, revision: revision(text), ...(schema ? { schema } : {}), format: file.endsWith(".properties") ? "properties" : file.endsWith(".jsonc") ? "jsonc" : "json", affectedServices: ["db", "tunnel", "qq", "llbot", "bds"] }; +} +export function validateDocument(document: ConfigDocument, text: string) { + if (Buffer.byteLength(text) > 2 * 1024 * 1024) throw new Error("配置文件过大"); + if (document.format === "properties") { + for (const line of text.split(/\r?\n/)) if (line.trim() && !line.trim().startsWith("#") && !/^[^=\r\n]+=.*$/.test(line)) throw new Error("server.properties 包含非法配置行"); + return; + } + const errors: ParseError[] = []; + const value: unknown = parse(text, errors, { allowTrailingComma: document.format === "jsonc", disallowComments: document.format === "json" }); + if (errors.length) throw Object.assign(new Error("配置语法错误"), { code: "validation", details: errors }); + if (document.schema) { + // 保留已有未知字段;只从校验副本排除未修改的未知字段,保存的原文仍完全保留。 + const schemaObject = document.schema as { additionalProperties?: boolean; properties?: Record }; + if (schemaObject.additionalProperties === false && value && typeof value === "object" && !Array.isArray(value)) { + const previous = parse(document.text || "{}") as Record; + for (const key of Object.keys(value)) if (!(key in (schemaObject.properties ?? {})) && previous && key in previous && JSON.stringify(previous[key]) === JSON.stringify((value as Record)[key])) delete (value as Record)[key]; + } + const hash = revision(JSON.stringify(document.schema)); + let validator = validators.get(hash); + if (!validator) { const schema = { ...document.schema } as Record; delete schema.$id; validator = ajv.compile(schema); validators.set(hash, validator); } + if (!validator(value)) throw Object.assign(new Error("配置不符合 Schema"), { code: "validation", details: validator.errors }); + } +} +export function saveDocument(key: string, expected: string, text: string) { + const document = readDocument(key); + if (document.revision !== expected) throw Object.assign(new Error("配置已被其他操作修改,请重新读取"), { code: "conflict" }); + validateDocument(document, text); + const file = configPath(key); const temp = `${file}.${randomUUID()}.tmp`; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(temp, text, { mode: 0o600 }); fs.renameSync(temp, file); + return document; +} diff --git a/packages/cli/src/management/deployment.ts b/packages/cli/src/management/deployment.ts new file mode 100644 index 00000000..4a5356ed --- /dev/null +++ b/packages/cli/src/management/deployment.ts @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import path from "node:path"; +import net from "node:net"; +import dgram from "node:dgram"; +import { ROOT } from "../runtime.js"; +export function deploymentPort(value: unknown, fallback: number) { + const port = value === undefined ? fallback : Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("端口必须为 1 到 65535 的整数"); + return port; +} +export async function deploymentPreflight(ports: { db: number; bds: number; bds6: number }) { + if (path.resolve(ROOT) === path.parse(ROOT).root) throw new Error("不能在磁盘根目录创建部署"); + for (const file of ["BDS/bedrock_server.exe", "BDS/bedrock_server", "data/sfmc_data.db"]) if (fs.existsSync(path.join(ROOT, file))) throw new Error("已有 BDS 或数据库,请接入现有部署,不覆盖数据"); + const space = fs.statfsSync(ROOT); + if (space.bavail * space.bsize < 1024 ** 3) throw new Error("可用空间不足 1 GiB,无法准备运行时、BDS 和维护备份"); + await new Promise((resolve, reject) => { + const server = net.createServer(); server.once("error", error => reject(new Error(`DB 端口 ${ports.db} 不可用: ${error.message}`))); + server.listen(ports.db, "0.0.0.0", () => server.close(error => error ? reject(error) : resolve())); + }); + for (const [port, type, address] of [[ports.bds, "udp4", "0.0.0.0"], [ports.bds6, "udp6", "::"]] as const) { + await new Promise((resolve, reject) => { + const socket = dgram.createSocket(type); + socket.once("error", error => { socket.close(); reject(new Error(`BDS 端口 ${port} 不可用: ${error.message}`)); }); + socket.bind(port, address, () => socket.close(() => resolve())); + }); + } +} diff --git a/packages/cli/src/management/domain.ts b/packages/cli/src/management/domain.ts new file mode 100644 index 00000000..eef25d22 --- /dev/null +++ b/packages/cli/src/management/domain.ts @@ -0,0 +1,105 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { spawn } from "node:child_process"; +import { resolveRegistryIndex } from "../registry.js"; +import { ROOT, resolveFetchModule } from "../runtime.js"; +import { resolveBdsContext, scanLocalModules } from "../pack-lifecycle.js"; +import { listInstalledWorldPacks, resolvePackRoots, readPackManifestInfo, installPackDirectory, enableInstalledPack, disableInstalledPack, worldPackParentDir } from "@sfmc-bds/bds-tools/world-packs"; +import { atomicJson } from "./tasks.js"; +import { isRetiredPlatformModule } from "@sfmc-bds/sdk/contracts"; + +export function readJson(file: string, fallback: T): T { + try { return JSON.parse(fs.readFileSync(file, "utf8")) as T; } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return fallback; throw error; } +} +export function identifier(value: unknown): string { + if (typeof value !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(value) || value.includes("..")) throw new Error("非法标识符"); + return value; +} +export async function listModules() { + const rows = (await scanLocalModules()).filter(row => !isRetiredPlatformModule(row.logicalId)); + return { modules: rows.map(row => ({ id: row.logicalId, folder: row.folderId, name: row.logicalId, version: row.version ?? "unknown", enabled: row.enabled, linked: fs.lstatSync(path.join(ROOT, "modules", "packages", row.folderId)).isSymbolicLink() })) }; +} +function fetchEntry() { const entry = resolveFetchModule(); if (!entry) throw new Error("找不到模块安装器"); return entry; } +export async function searchModules(query: string) { + const result = await resolveRegistryIndex(); + return { modules: Object.entries(result.index).filter(([id]) => !isRetiredPlatformModule(id) && id.includes(query)).map(([id, metadata]) => ({ id, ...metadata })), stale: result.stale }; +} +export async function runFetch(args: string[]) { + await runProcess(process.execPath, [fetchEntry(), ...args], ROOT); + return { changed: true }; +} +export function runProcess(command: string, args: string[], cwd: string, onLine?: (text: string) => void): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, env: { ...process.env, SFMC_ROOT: ROOT, FORCE_COLOR: "0" }, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); + let diagnostic = ""; + for (const stream of [child.stdout, child.stderr]) stream.on("data", (chunk: Buffer) => { const text = chunk.toString(); diagnostic = (diagnostic + text).slice(-6000); onLine?.(text); }); + child.on("error", reject); child.on("close", code => code === 0 ? resolve() : reject(new Error(`子进程失败 (${code}): ${diagnostic}`))); + }); +} +export async function modulePlan(ids: string[] = []) { + const url = pathToFileURL(path.join(path.dirname(fetchEntry()), "lib", "module-update.mjs")).href; + const api = await import(url) as { planModuleUpdates: (options: { root: string; ids: string[] }) => Promise }; + return api.planModuleUpdates({ root: ROOT, ids }); +} +export async function applyModulePlan(ids: string[]) { + const directory = path.dirname(fetchEntry()); + const api = await import(pathToFileURL(path.join(directory, "lib", "module-update.mjs")).href) as { + planModuleUpdates: (options: { root: string; ids: string[] }) => Promise<{ upgrades: { id: string; spec: string }[]; failMode: "abort" | "continue" }>; + applyModuleUpgrades: (upgrades: unknown[], deps: object) => Promise<{ failed: { id: string; message: string }[] }>; + resyncInstalledCatalog: (id: string) => void; + }; + const fetchApi = await import(pathToFileURL(fetchEntry()).href) as { installPreservingLock: (id: string, spec: string) => Promise }; + const plan = await api.planModuleUpdates({ root: ROOT, ids }); + const result = await api.applyModuleUpgrades(plan.upgrades, { root: ROOT, failMode: plan.failMode, install: fetchApi.installPreservingLock, resync: api.resyncInstalledCatalog }); + if (result.failed.length) throw new Error(JSON.stringify(result.failed)); + return result; +} +export function listPacks() { + const context = resolveBdsContext(); + return { packs: listInstalledWorldPacks(context.bdsRoot, context.levelName).map(pack => ({ id: pack.uuid, name: pack.name, kind: pack.kind, enabled: pack.enabled, version: pack.version.join(".") })) }; +} +export async function togglePack(id: string, enabled: boolean) { + const context = resolveBdsContext(); + const pack = listInstalledWorldPacks(context.bdsRoot, context.levelName).find(row => row.uuid === id); + if (!pack) throw new Error("找不到世界包"); + if (enabled) await enableInstalledPack({ ...context, info: { uuid: pack.uuid, version: pack.version, kind: pack.kind, name: pack.name } }); + else await disableInstalledPack({ ...context, kind: pack.kind, packUuid: pack.uuid, version: pack.version }); + return { changed: true, restartRequired: true }; +} +export async function importPack(filename: string) { + const inbox = path.resolve(ROOT, "packs", "_desktop-inbox"); + const file = path.resolve(inbox, identifier(filename)); + if (!file.startsWith(inbox + path.sep) || !fs.existsSync(file)) throw new Error("世界包尚未上传到实例收件箱"); + const context = resolveBdsContext(); + const resolved = await resolvePackRoots(file); + try { + if (!resolved.roots.length) throw new Error("归档不含可识别的世界包"); + for (const root of resolved.roots) { + const info = readPackManifestInfo(root); + if (!info) throw new Error("世界包 manifest 无效"); + const result = await installPackDirectory({ srcDir: root, destParent: worldPackParentDir(context.bdsRoot, context.levelName, info.kind) }); + if (!result.ok) throw new Error(result.reason ?? "安装失败;可能与已有包冲突"); + await enableInstalledPack({ ...context, info }); + } + } finally { resolved.dispose?.(); } + return { changed: true, restartRequired: true }; +} +export function playerFiles() { + const bds = resolveBdsContext().bdsRoot; + return { allowlist: path.join(bds, "allowlist.json"), permissions: path.join(bds, "permissions.json"), sfmcPermissions: path.join(ROOT, "configs", "permissions.json") }; +} +export function savePlayerPermissions(kind: string, entries: unknown) { + const files = playerFiles(); + if (!(kind in files) || !Array.isArray(entries)) throw new Error("非法玩家权限类型或内容"); + for (const entry of entries) { + if (!entry || typeof entry !== "object") throw new Error("权限条目必须为对象"); + const row = entry as Record; + if (kind === "allowlist" && (typeof row.name !== "string" || /[\r\n]/.test(row.name))) throw new Error("允许名单需提供合法玩家名称"); + if (kind === "permissions" && (typeof row.xuid !== "string" || !/^\d+$/.test(row.xuid) || !["visitor", "member", "operator"].includes(String(row.permission)))) throw new Error("BDS 权限需有效 XUID 和权限等级"); + if (kind === "sfmcPermissions" && (typeof row.player_name !== "string" || !row.player_name.trim() || /[\r\n\0]/.test(row.player_name) || !Number.isInteger(row.level) || Number(row.level) < 0 || Number(row.level) > 3)) throw new Error("SFMC 权限需合法玩家名及 0 到 3 的整数等级"); + } + atomicJson(files[kind as keyof typeof files], entries); + return { saved: true }; +} diff --git a/packages/cli/src/management/maintenance.ts b/packages/cli/src/management/maintenance.ts new file mode 100644 index 00000000..c996844b --- /dev/null +++ b/packages/cli/src/management/maintenance.ts @@ -0,0 +1,189 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { ROOT, isMonorepoLayout } from "../runtime.js"; +import { queryServicesRuntimeLocal, services, START_ORDER, type ServiceName } from "../services.js"; +import { resolveBdsContext } from "../pack-lifecycle.js"; +import { atomicJson, type TaskContext, type TaskStore } from "./tasks.js"; +import { readJson, runProcess } from "./domain.js"; +import type { AttachmentPlan } from "@sfmc-bds/management"; +import { isDaemonServer } from "../daemon/role.js"; +import { callDaemon } from "../daemon/client.js"; + +async function runtimeRows() { + if (isDaemonServer()) return queryServicesRuntimeLocal(); + const result = await callDaemon("status"); + if (result.kind !== "status") throw new Error("无法读取守护进程服务状态"); + return result.rows; +} + +const require = createRequire(import.meta.url); +export function platformVersion(): string { + if (isMonorepoLayout(ROOT)) return readJson<{ version: string }>(path.join(ROOT, "packages", "meta", "package.json"), { version: "unknown" }).version; + if (process.env.SFMC_PLATFORM_VERSION) return process.env.SFMC_PLATFORM_VERSION; + const active = readJson<{ version?: string }>(path.join(ROOT, ".sfmc", "runtime", "active.json"), {}); + if (active.version) return active.version; + const deployed = readJson<{ version?: string }>(path.join(ROOT, "node_modules", "@sfmc-bds", "sfmc", "package.json"), {}); + if (deployed.version) return deployed.version; + try { return (require("@sfmc-bds/sfmc/package.json") as { version: string }).version; } + catch { return (readJson<{ version: string }>(path.join(fileURLToPath(new URL("../../..", import.meta.url)), "package.json"), { version: "unknown" })).version; } +} +export async function attachmentPlan(): Promise { + const development = isMonorepoLayout(ROOT); + const currentVersion = platformVersion(); + let targetVersion = currentVersion; + if (!development) { + const response = await fetch("https://registry.npmjs.org/@sfmc-bds%2fsfmc/latest", { signal: AbortSignal.timeout(15_000) }); + if (!response.ok) throw new Error(`平台版本检查失败 (${response.status})`); + const metadata = await response.json() as { version?: string }; + if (!metadata.version || !/^\d+\.\d+\.\d+$/.test(metadata.version)) throw new Error("latest 没有指向有效的稳定平台版本"); + targetVersion = metadata.version; + } + const rows = await runtimeRows(); + return { currentVersion, targetVersion, development, upgradeRequired: !development && targetVersion !== currentVersion, externalServices: rows.filter(row => row.running && row.ownership === "external").map(row => row.name), steps: development ? ["保留源码工作区,连接当前开发部署"] : ["下载固定版本的平台依赖组合", "停止受影响服务并确认进程退出", "备份配置、模块、数据库与世界", "切换平台并验证", "恢复此前运行的服务"] }; +} +export async function stopForMaintenance(names: ServiceName[], phase: TaskContext["phase"]): Promise { + const before = await queryServicesRuntimeLocal(); + const affected = before.filter(row => names.includes(row.name) && row.running); + if (affected.some(row => row.ownership === "external")) throw Object.assign(new Error("存在无法通过当前守护进程优雅停止的外部服务;请先通过原管理器停止后重新接入"), { code: "conflict" }); + phase("stop-services", "优雅停止并确认进程退出"); + const running = affected.map(row => row.name); + for (const name of [...START_ORDER].reverse().filter(name => names.includes(name))) await services[name].stop(false, false); + const after = await queryServicesRuntimeLocal(); + if (after.some(row => names.includes(row.name) && row.running)) throw new Error("服务未完全退出,已中止文件操作"); + return running; +} +export async function restoreRunning(names: ServiceName[], phase: TaskContext["phase"]) { + phase("restore", "恢复维护前运行的服务"); + for (const name of START_ORDER.filter(name => names.includes(name))) { + const result = await services[name].start(); + if (result.status === "skipped") throw new Error(`恢复 ${name} 失败: ${result.reason}`); + } + await verifyRunning(names); +} +export async function verifyRunning(names: ServiceName[]) { + if (!names.length) return; + await new Promise(resolve => setTimeout(resolve, 2000)); + for (const name of names) { + if (!services[name].running || !services[name].pid) throw new Error(`${name} 启动后已退出`); + } + if (names.includes("db")) { + const cfg = readJson<{ db_port?: number; http_auth?: string }>(path.join(ROOT, "configs", "db_config.json"), {}); + const response = await fetch(`http://127.0.0.1:${cfg.db_port ?? 3001}/api/sfmc/status`, { headers: cfg.http_auth ? { authorization: `Bearer ${cfg.http_auth}` } : {}, signal: AbortSignal.timeout(5000) }); + if (!response.ok) throw new Error(`DB 健康检查失败 (${response.status})`); + } +} +function snapshotTargets() { + const db = readJson<{ dbDir?: string }>(path.join(ROOT, "configs", "db_config.json"), {}); + const bds = resolveBdsContext().bdsRoot; + return { configs: path.join(ROOT, "configs"), modules: path.join(ROOT, "modules"), data: path.dirname(path.resolve(ROOT, db.dbDir ?? "data/sfmc_data.db")), bds }; +} +export async function createSnapshot(id: string, phase: TaskContext["phase"]) { + phase("backup", "创建已停服的一致备份"); + const destination = path.join(ROOT, ".sfmc", "backups", id); + fs.mkdirSync(destination, { recursive: true }); + const targets = snapshotTargets(); + for (const target of Object.values(targets)) { + const absolute = path.resolve(target); + if (absolute === path.parse(absolute).root || absolute === path.resolve(ROOT) || destination.startsWith(absolute + path.sep)) throw new Error("备份目标包含实例根目录或备份目录,已中止"); + } + for (const [key, target] of Object.entries(targets)) { + if (fs.existsSync(target)) await fs.promises.cp(target, path.join(destination, key), { recursive: true, dereference: false }); + } + atomicJson(path.join(destination, "manifest.json"), { createdAt: new Date().toISOString(), targets, complete: true }); + return destination; +} +export async function restoreSnapshot(id: string, phase: TaskContext["phase"]) { + if (!/^[a-zA-Z0-9-]{1,80}$/.test(id)) throw new Error("非法备份编号"); + const source = path.join(ROOT, ".sfmc", "backups", id); + const manifest = readJson<{ complete: boolean; targets: Record }>(path.join(source, "manifest.json"), { complete: false, targets: {} }); + if (!manifest.complete) throw new Error("备份不完整,禁止恢复"); + const targets = snapshotTargets(); + phase("restore-data", "按用户明确选择恢复配置、模块、数据库和世界"); + for (const [key, target] of Object.entries(targets)) { + if (path.resolve(manifest.targets[key] ?? "") !== path.resolve(target)) throw new Error("备份目标与当前部署不一致"); + const saved = path.join(source, key); + if (!fs.existsSync(saved)) continue; + const absolute = path.resolve(target); + if (absolute === path.parse(absolute).root || absolute === path.resolve(ROOT) || fs.lstatSync(saved).isSymbolicLink()) throw new Error("不安全的恢复目标"); + await fs.promises.rm(absolute, { recursive: true, force: true }); + await fs.promises.cp(saved, absolute, { recursive: true, dereference: false }); + } + return { restored: id }; +} +/** 只回退程序内容,世界、玩家权限和数据库始终保留在当前状态。 */ +async function restoreProgramSnapshot(id: string, kind: "modules" | "bds") { + const source = path.join(ROOT, ".sfmc", "backups", id); + const manifest = readJson<{ complete: boolean; targets: Record }>(path.join(source, "manifest.json"), { complete: false, targets: {} }); + const targets = snapshotTargets(); + if (!manifest.complete || path.resolve(manifest.targets[kind] ?? "") !== path.resolve(targets[kind])) throw new Error("程序备份不完整或目标不匹配"); + const saved = path.join(source, kind); + if (kind === "modules") { + if (!fs.existsSync(saved)) return; + await fs.promises.rm(targets.modules, { recursive: true, force: true }); + await fs.promises.cp(saved, targets.modules, { recursive: true, dereference: false }); + return; + } + const cfg = readJson<{ preserve?: string[] }>(path.join(ROOT, "configs", "bds_updater.json"), {}); + const preserved = new Set(["worlds", "config", "server.properties", "allowlist.json", "whitelist.json", "permissions.json", ...(cfg.preserve ?? []).map(name => name.split(/[\\/]/)[0]!)]); + if (!fs.existsSync(saved)) throw new Error("缺少原 BDS 程序备份"); + const target = path.resolve(targets.bds); + if (target === path.parse(target).root || target === path.resolve(ROOT)) throw new Error("不安全的程序回退目标"); + fs.mkdirSync(target, { recursive: true }); + for (const entry of fs.readdirSync(target)) if (!preserved.has(entry)) await fs.promises.rm(path.join(target, entry), { recursive: true, force: true }); + for (const entry of fs.readdirSync(saved)) if (!preserved.has(entry)) await fs.promises.cp(path.join(saved, entry), path.join(target, entry), { recursive: true, dereference: false }); +} +export async function withStoppedServices(context: TaskContext, names: ServiceName[], action: () => Promise, backup = true, rollbackPrograms?: "modules" | "bds"): Promise { + const running = await stopForMaintenance(names, context.phase); + try { + if (backup) await createSnapshot(context.id, context.phase); + context.phase("execute"); + const result = await action(); + context.phase("verify"); + await restoreRunning(running, context.phase); + return result; + } catch (error) { + // 失败后保持停服,不猜测未知数据迁移是否可以安全恢复运行。 + for (const name of [...START_ORDER].reverse().filter(name => running.includes(name))) await services[name].stop(false, false).catch(() => {}); + let programRolledBack = false; + let rollbackError: string | undefined; + if (rollbackPrograms) { + context.phase("rollback-program", "回退程序版本,保留当前玩家数据"); + try { await restoreProgramSnapshot(context.id, rollbackPrograms); programRolledBack = true; } + catch (failure) { rollbackError = String(failure); } + } + throw Object.assign(new Error(`维护失败,服务保持停止;备份编号 ${context.id}: ${error instanceof Error ? error.message : String(error)}`), { code: "io", details: { backupId: context.id, programRolledBack, dataRestoreRequired: Boolean(rollbackPrograms), ...(rollbackError ? { rollbackError } : {}), ...((error as { details?: object }).details ?? {}) } }); + } +} +export async function launchPlatformUpdate(store: TaskStore, context: TaskContext, target: string) { + if (isMonorepoLayout(ROOT)) throw new Error("源码部署不覆盖工作区;请使用正常开发发布流程"); + if (!/^\d+\.\d+\.\d+$/.test(target)) throw new Error("需要固定稳定版本"); + const rows = await runtimeRows(); + if (rows.some(row => row.running && row.ownership === "external")) throw new Error("外部服务必须先通过原管理器优雅停止"); + const request = path.join(ROOT, ".sfmc", "operations", `${context.id}.request.json`); + const previous = readJson(path.join(ROOT, ".sfmc", "runtime", "active.json"), { entry: fs.existsSync(path.join(ROOT, "node_modules", "@sfmc-bds", "sfmc", "bin", "sfmc.mjs")) ? path.join(ROOT, "node_modules", "@sfmc-bds", "sfmc", "bin", "sfmc.mjs") : process.env.SFMC_DAEMON_ENTRY, version: platformVersion() }); + atomicJson(request, { root: ROOT, id: context.id, target, running: rows.filter(row => row.running).map(row => row.name), previous }); + const entry = fileURLToPath(new URL("./platform-worker.js", import.meta.url)); + fs.mkdirSync(path.join(ROOT, ".sfmc", "logs"), { recursive: true }); + const fd = fs.openSync(path.join(ROOT, ".sfmc", "logs", "maintenance.log"), "a"); + const child = spawn(process.execPath, [entry, request], { cwd: ROOT, env: { ...process.env, SFMC_ROOT: ROOT }, detached: true, windowsHide: true, stdio: ["ignore", fd, fd] }); + fs.closeSync(fd); + await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); }); + child.unref(); + const record = store.get(context.id) as ReturnType & { workerPid?: number }; + record.status = "running"; if (child.pid) record.workerPid = child.pid; + store.save(record); + return { detached: true }; +} +export async function runPnpm(args: string[], cwd: string) { + const script = process.env.SFMC_PNPM_ENTRY; + if (script) return runProcess(process.execPath, [script, ...args], cwd); + const command = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + const crossSpawn = require("cross-spawn") as typeof spawn; + await new Promise((resolve, reject) => { + const child = crossSpawn(command, args, { cwd, env: process.env, windowsHide: true, stdio: "ignore" }); + child.on("error", reject); child.on("close", code => code === 0 ? resolve() : reject(new Error(`pnpm 执行失败 (${code})`))); + }); +} diff --git a/packages/cli/src/management/platform-worker.ts b/packages/cli/src/management/platform-worker.ts new file mode 100644 index 00000000..42d00b86 --- /dev/null +++ b/packages/cli/src/management/platform-worker.ts @@ -0,0 +1,129 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import { withMaintenanceLock } from "@sfmc-bds/management/node"; +import { toManagementError, type OperationRecord } from "@sfmc-bds/management"; +import { atomicJson } from "./tasks.js"; +import { readJson, runProcess } from "./domain.js"; +import { createSnapshot, runPnpm } from "./maintenance.js"; +import { callDaemon, disconnectDaemonClient } from "../daemon/client.js"; +import { ROOT } from "../runtime.js"; +import { START_ORDER } from "../services.js"; +import { readDaemonMeta } from "../daemon/paths.js"; + +/** 本进程在独立 Node 中常驻,平台换包不会删除其当前版本目录。 */ +async function main() { + const requestFile = process.argv[2]; + if (!requestFile) throw new Error("缺少维护任务文件"); + const request = readJson<{ id: string; target: string; running: string[]; previous: { entry: string } | null }>(requestFile, { id: "", target: "", running: [], previous: null }); + if (!/^[\w-]+$/.test(request.id) || !/^\d+\.\d+\.\d+$/.test(request.target)) throw new Error("非法维护请求"); + const recordFile = path.join(ROOT, ".sfmc", "operations", `${request.id}.json`); + let record = readJson(recordFile, {} as OperationRecord); + const phase = (name: string, message?: string) => { + record = readJson(recordFile, record); + const previous = record.phases.at(-1); if (previous?.status === "running") previous.status = "done"; + record.phases.push({ name, status: "running", ...(message ? { message } : {}) }); record.status = "running"; record.updatedAt = new Date().toISOString(); atomicJson(recordFile, record); + }; + const runtime = path.join(ROOT, ".sfmc", "runtime"); + const release = path.join(runtime, "releases", `${request.target}-${request.id}`); + const activeFile = path.join(runtime, "active.json"); + let switched = false; + let preparedEntry = ""; + const waitExit = async (pid: number) => { + const deadline = Date.now() + 30_000; + for (;;) { + let alive = true; try { process.kill(pid, 0); } catch (error) { alive = (error as NodeJS.ErrnoException).code !== "ESRCH"; } + if (!alive) return; + if (Date.now() > deadline) throw new Error("守护进程尚未退出,禁止切换平台"); + await new Promise(resolve => setTimeout(resolve, 100)); + } + }; + const shutdown = async () => { + const meta = readDaemonMeta(); + if (!meta) return; + const status = await callDaemon("status"); + if (status.kind !== "status") throw new Error("无法确认当前服务状态"); + if (status.rows.some(row => row.running)) { + const stopped = await callDaemon("maintenanceStop"); + if (stopped.kind !== "status" || stopped.rows.some(row => row.running)) throw new Error("服务没有优雅退出"); + } + await callDaemon("shutdown"); disconnectDaemonClient(); await waitExit(meta.pid); + }; + try { + // 提交进程保存 workerPid 后才释放锁;等待接管,禁止竞态覆盖记录。 + await new Promise(resolve => setTimeout(resolve, 500)); + let acquired = false; + for (let attempt = 0; attempt < 100 && !acquired; attempt++) { + try { + await withMaintenanceLock(ROOT, async () => { + acquired = true; + try { + phase("prepare", `准备平台 ${request.target}`); + fs.mkdirSync(release, { recursive: true }); + atomicJson(path.join(release, "package.json"), { private: true, dependencies: {} }); + await runPnpm(["add", "--ignore-workspace", "--prod", "--ignore-scripts", "--save-exact", `@sfmc-bds/sfmc@${request.target}`], release); + const entry = path.join(release, "node_modules", "@sfmc-bds", "sfmc", "bin", "sfmc.mjs"); + if (!fs.existsSync(entry)) throw new Error("平台入口不存在"); + // 发布的平台必须包含本次桌面所需管理协议。 + const resolver = createRequire(entry); + const cli = path.join(path.dirname(resolver.resolve("@sfmc-bds/cli/package.json")), "dist", "management", "stdio.js"); + if (!fs.existsSync(cli)) throw new Error("最新平台尚未发布管理入口,保留当前部署;请先完成平台发版"); + const targetSdk = readJson<{ version: string }>(resolver.resolve("@sfmc-bds/sdk/package.json"), { version: "" }).version; + const { satisfiesSdk } = await import(pathToFileURL(path.join(path.dirname(cli), "../..", "scripts/module-install/lib/semver.mjs")).href) as { satisfiesSdk: (version: string, range: string) => boolean }; + const modules = path.join(ROOT, "modules", "packages"); + if (fs.existsSync(modules)) for (const folder of fs.readdirSync(modules)) { + const pkg = readJson<{ peerDependencies?: Record }>(path.join(modules, folder, "package.json"), {}); + const manifest = readJson<{ sdk?: string }>(path.join(modules, folder, "sapi", "manifest.json"), {}); + const range = pkg.peerDependencies?.["@sfmc-bds/sdk"] ?? manifest.sdk; + if (range && !satisfiesSdk(targetSdk, range)) throw new Error(`模块 ${folder} 与目标 SDK ${targetSdk} 不兼容 (${range});保留固定版本及本地链接,已停止升级`); + } + preparedEntry = entry; + phase("stop-services"); + const lock = readJson<{ nonce: string }>(path.join(ROOT, ".sfmc", "maintenance.lock"), { nonce: "" }); + process.env.SFMC_MAINTENANCE_TOKEN = lock.nonce; + await shutdown(); + await createSnapshot(request.id, phase); + phase("execute"); atomicJson(activeFile, { entry, version: request.target, pnpm: process.env.SFMC_PNPM_ENTRY }); switched = true; + const launcher = path.join(runtime, "launcher.mjs"); + fs.writeFileSync(launcher, "import fs from 'node:fs'; import path from 'node:path'; import {pathToFileURL,fileURLToPath} from 'node:url'; const base=path.dirname(fileURLToPath(import.meta.url)); const active=JSON.parse(fs.readFileSync(path.join(base,'active.json'),'utf8')); process.env.SFMC_DAEMON_ENTRY=fileURLToPath(import.meta.url); process.env.SFMC_NODE_BINARY=process.execPath; if(active.pnpm) process.env.SFMC_PNPM_ENTRY=active.pnpm; await import(pathToFileURL(active.entry).href);\n"); + for (const key of Object.keys(process.env)) if (key.startsWith("SFMC_SERVICE_") && key.endsWith("_ENTRY") || ["SFMC_FETCH_MODULE", "SFMC_PLATFORM_VERSION"].includes(key)) delete process.env[key]; + process.env.SFMC_DAEMON_ENTRY = launcher; + phase("verify"); + await runProcess(process.execPath, [launcher, "status"], ROOT); + const handshake = await callDaemon("management", { request: { id: request.id, method: "handshake" } }); + if (handshake.kind !== "management" || !handshake.response.ok || (handshake.response.result as { platformVersion?: string }).platformVersion !== request.target) throw new Error("目标平台握手验证失败"); + phase("restore"); + for (const service of START_ORDER.filter(name => request.running.includes(name))) await callDaemon("start", { name: service }); + await new Promise(resolve => setTimeout(resolve, 3000)); + const status = await callDaemon("status"); + if (status.kind !== "status" || request.running.some(name => !status.rows.some(row => row.name === name && row.running))) throw new Error("此前运行的服务未全部恢复"); + record.result = { version: request.target, backupId: request.id }; record.status = "succeeded"; + } catch (error) { + // 回退也必须在同一把锁内完成,防止其他客户端在入口切换期间写入。 + let rolledBack = false; + if (switched && request.previous) { + try { await shutdown(); atomicJson(activeFile, request.previous); rolledBack = true; } + catch (rollbackError) { phase("rollback-blocked", `无法安全停止目标版本;保留入口和备份,不覆盖玩家数据: ${String(rollbackError)}`); } + } + record.result = { programRolledBack: rolledBack, backupId: request.id, dataRestoreRequired: switched, preparedEntry }; + throw error; + } + }); + } catch (error) { + if (!acquired && (error as { code?: string }).code === "locked") { await new Promise(resolve => setTimeout(resolve, 200)); continue; } + throw error; + } + } + if (!acquired) throw new Error("无法接管维护锁"); + } catch (error) { + record.status = "failed"; record.error = toManagementError(error); + record.result ??= { programRolledBack: false, backupId: request.id, dataRestoreRequired: switched, preparedEntry }; + } finally { + record.updatedAt = new Date().toISOString(); const last = record.phases.at(-1); if (last) last.status = record.status === "succeeded" ? "done" : "failed"; + atomicJson(recordFile, record); + fs.rmSync(requestFile, { force: true }); + disconnectDaemonClient(); + } +} +void main().catch(error => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }); diff --git a/packages/cli/src/management/server.ts b/packages/cli/src/management/server.ts new file mode 100644 index 00000000..9d2c5080 --- /dev/null +++ b/packages/cli/src/management/server.ts @@ -0,0 +1,193 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { EventEmitter } from "node:events"; +import { MANAGEMENT_PROTOCOL_VERSION, type ManagementRequest, type ManagementEvent } from "@sfmc-bds/management"; +import { ROOT, isRuntimeInitialized, spawnService } from "../runtime.js"; +import { queryServicesRuntimeLocal, services, START_ORDER, SERVICE_NAMES, refreshServices, onServiceStateChange, type ServiceName } from "../services.js"; +import { readDiskLogs, onLog } from "../logs.js"; +import { cmdModuleEnable, cmdModuleDisable } from "../module-commands.js"; +import { ensureCoreConfigs } from "@sfmc-bds/sdk/node/config"; +import { TaskStore, atomicJson, type TaskContext } from "./tasks.js"; +import { configFiles, readDocument, saveDocument, validateDocument } from "./config.js"; +import { listModules, searchModules, identifier, runFetch, readJson, listPacks, togglePack, importPack, playerFiles, savePlayerPermissions, modulePlan, applyModulePlan } from "./domain.js"; +import { attachmentPlan, platformVersion, withStoppedServices, launchPlatformUpdate, restoreSnapshot, verifyRunning } from "./maintenance.js"; +import { startupPlan } from "./startup.js"; +import { cmdPackBuild, cmdPackDeploy } from "../pack-lifecycle.js"; +import { withMaintenanceLock } from "@sfmc-bds/management/node"; +import { deploymentPreflight, deploymentPort } from "./deployment.js"; +import { resolveBdsContext } from "../pack-lifecycle.js"; + +const events = new EventEmitter(); +let tasks: TaskStore | undefined; +export function managementTasks() { + if (!tasks) { + tasks = new TaskStore(ROOT); + tasks.on("operation", payload => events.emit("event", { type: "event", event: "operation", instanceId: ROOT, payload })); + onLog(log => events.emit("event", { type: "event", event: "log", instanceId: ROOT, payload: { ...log, time: log.time.toISOString() } })); + onServiceStateChange(payload => events.emit("event", { type: "event", event: "serviceState", instanceId: ROOT, payload })); + } + return tasks; +} +export function onManagementEvent(callback: (event: ManagementEvent) => void) { events.on("event", callback); return () => events.off("event", callback); } +function params(request: ManagementRequest): Record { + if (request.params === undefined) return {}; + if (!request.params || typeof request.params !== "object" || Array.isArray(request.params)) throw new Error("参数必须为对象"); + return request.params as Record; +} +function string(value: unknown, label: string) { if (typeof value !== "string" || value.length > 2 * 1024 * 1024) throw new Error(`非法 ${label}`); return value; } +function service(value: unknown): ServiceName { if (!SERVICE_NAMES.includes(value as ServiceName)) throw new Error("未知服务"); return value as ServiceName; } +async function rebuildModules() { + const build = await cmdPackBuild([]); if (!build.ok) throw new Error(build.message); + const deploy = await cmdPackDeploy([]); if (!deploy.ok) throw new Error(deploy.message); + return { changed: true }; +} +async function players() { + const files = playerFiles(); const allowlist = readJson(files.allowlist, []); + const cfg = readJson<{ db_port?: number; http_auth?: string }>(path.join(ROOT, "configs", "db_config.json"), {}); + let data: { online?: { name: string }[]; updatedAt?: number } = {}; + try { + const response = await fetch(`http://127.0.0.1:${cfg.db_port ?? 3001}/api/sfmc/status`, { headers: cfg.http_auth ? { authorization: `Bearer ${cfg.http_auth}` } : {}, signal: AbortSignal.timeout(5000) }); + if (response.ok) data = await response.json() as typeof data; + } catch { /* 未连接时使用未知状态 */ } + const fresh = typeof data.updatedAt === "number" && Date.now() - data.updatedAt < 60_000; + return { players: (data.online ?? []).map(row => ({ name: row.name, xuid: "", online: fresh ? true : null })), updatedAt: data.updatedAt ? new Date(data.updatedAt).toISOString() : "", fresh, allowlist, permissions: readJson(files.permissions, []), sfmcPermissions: readJson(files.sfmcPermissions, []) }; +} +async function bdsUpdate(context: TaskContext, checkOnly = false) { + context.phase("download", checkOnly ? "检查 BDS 更新" : "更新 BDS"); + const args = checkOnly ? ["--check-only", "--no-notify"] : ["--no-start", "--no-notify"]; + let output = ""; + await new Promise((resolve, reject) => { + const child = spawnService("update", args, { stdio: "pipe", cwd: ROOT }); + for (const stream of [child.stdout, child.stderr]) stream?.on("data", chunk => { output = (output + String(chunk)).slice(-64_000); }); + child.on("error", reject); child.on("close", code => code === 0 ? resolve() : reject(new Error(`BDS 更新失败 (${code})`))); + }); + return { checked: checkOnly, updated: /SFMC_UPDATE_RESULT=deployed/.test(output), currentVersion: /CURRENT=([^\r\n]+)/.exec(output)?.[1] ?? "unknown", latestVersion: /LATEST=([^\r\n]+)/.exec(output)?.[1] ?? "unknown", result: /SFMC_UPDATE_RESULT=([^\r\n]+)/.exec(output)?.[1] ?? "unknown" }; +} +export async function dispatchManagement(request: ManagementRequest): Promise { + const p = params(request); const store = managementTasks(); + switch (request.method) { + case "handshake": return { protocolVersion: MANAGEMENT_PROTOCOL_VERSION, platformVersion: platformVersion(), host: { os: process.platform === "win32" ? "windows" : process.platform, arch: process.arch, release: os.release() }, root: ROOT, capabilities: ["services", "logs", "modules", "config", "packs", "players", "updates", "operations", "metrics"], daemonPid: process.pid, daemonStartedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(), initialized: isRuntimeInitialized() || fs.existsSync(path.join(resolveBdsContext().bdsRoot, process.platform === "win32" ? "bedrock_server.exe" : "bedrock_server")) }; + case "events.subscribe": return { subscribed: true }; + case "services.list": return { rows: await queryServicesRuntimeLocal() }; + case "services.start": case "services.stop": case "services.restart": { + const names = p.name === "all" ? [...START_ORDER] : [service(p.name)]; + return store.submit(request.method, async context => { + context.phase("execute"); + const rows = await queryServicesRuntimeLocal(); + if (rows.some(row => names.includes(row.name) && row.running && row.ownership === "external") && request.method !== "services.start") throw new Error("外部进程无法安全停止,请通过原管理器操作"); + if (request.method !== "services.start") for (const name of [...names].reverse()) await services[name].stop(false, false); + if (request.method !== "services.stop") { + const outcomes = []; + for (const name of names) outcomes.push({ name, ...(await services[name].start()) }); + await verifyRunning(outcomes.filter(row => row.status !== "skipped").map(row => row.name)); + return { outcomes }; + } + return { stopped: names }; + }); + } + case "services.send": { + const name = service(p.name); const message = string(p.message, "命令"); + if (/[\r\n]/.test(message) || !message.trim()) throw new Error("一次只能发送一行命令"); + const target = services[name]; if (!target.proc?.stdin || !target.running) throw new Error("服务未托管或没有命令输入通道"); + return withMaintenanceLock(ROOT, async () => { target.proc!.stdin!.write(message + "\n"); return { delivered: true }; }); + } + case "logs.tail": return { entries: readDiskLogs({ limit: Math.min(5000, Math.max(1, Number(p.limit) || 1000)), ...(Array.isArray(p.sources) ? { sources: p.sources.map(value => string(value, "日志来源")) } : {}), ...(Array.isArray(p.levels) ? { levels: p.levels as ("info" | "warn" | "error")[] } : {}) }).map(log => ({ ...log, time: log.time.toISOString() })) }; + case "operations.list": return { operations: store.list().filter(row => !Array.isArray(p.statuses) || p.statuses.includes(row.status)).slice(0, Math.min(Number(p.limit) || 100, 200)) }; + case "operations.get": return { operation: store.get(string(p.operationId, "任务编号")) }; + case "modules.list": return listModules(); + case "modules.search": return searchModules(typeof p.query === "string" ? p.query : ""); + case "modules.install": case "modules.uninstall": case "modules.toggle": { + const id = identifier(p.id); + if (request.method !== "modules.toggle") { + const linked = (await listModules()).modules.find(row => row.id === id || row.folder === id)?.linked; + if (linked) throw new Error("保留本地链接模块,不通过包安装或卸载覆盖开发目录"); + } + return store.submit(request.method, context => withStoppedServices(context, [...START_ORDER], async () => { + if (request.method === "modules.toggle") { + const result = await (p.enabled === true ? cmdModuleEnable([id]) : cmdModuleDisable([id])); if (!result.ok) throw new Error(result.message); + } else { + const args = [request.method === "modules.install" ? "install" : "uninstall", id]; + if (request.method === "modules.install" && typeof p.source === "string" && p.source) args.push("--from", p.source); + await runFetch(args); + } + return rebuildModules(); + }, true, "modules")); + } + case "config.list": return { keys: [...configFiles().keys()] }; + case "config.read": return readDocument(string(p.key, "配置名称")); + case "config.apply": { + const key = string(p.key, "配置名称"); const text = string(p.text, "配置内容"); const expected = string(p.revision, "配置版本"); + const document = readDocument(key); validateDocument(document, text); + if (document.revision !== expected) throw Object.assign(new Error("配置已变更,请重新读取"), { code: "conflict" }); + return store.submit(request.method, async context => { + let saved = false; + try { return await withStoppedServices(context, [...START_ORDER], async () => { + saveDocument(key, expected, text); context.phase("apply", "文件已保存,正在应用运行配置"); + saved = true; + refreshServices(); return { saved: true, revision: readDocument(key).revision, applied: true }; + }); } catch (error) { throw Object.assign(new Error(`${saved ? "文件已保存,运行时应用失败" : "配置未保存"}: ${error instanceof Error ? error.message : String(error)}`), { code: "io", details: { saved, applied: false, backupId: context.id } }); } + }); + } + case "packs.list": return listPacks(); + case "packs.import": return store.submit(request.method, context => withStoppedServices(context, [...START_ORDER], () => importPack(identifier(p.filename)))); + case "packs.toggle": return store.submit(request.method, context => withStoppedServices(context, [...START_ORDER], () => togglePack(identifier(p.id), p.enabled === true))); + case "players.list": return players(); + case "metrics.read": { + const cfg = readJson<{ db_port?: number; http_auth?: string }>(path.join(ROOT, "configs", "db_config.json"), {}); + const response = await fetch(`http://127.0.0.1:${cfg.db_port ?? 3001}/api/sfmc/metrics`, { headers: cfg.http_auth ? { authorization: `Bearer ${cfg.http_auth}` } : {}, signal: AbortSignal.timeout(10_000) }); + if (!response.ok) throw new Error(`运行指标读取失败 (${response.status})`); + return response.json(); + } + case "players.apply": return store.submit(request.method, context => withStoppedServices(context, [...START_ORDER], async () => savePlayerPermissions(string(p.kind, "权限类型"), p.entries))); + case "attachment.plan": return attachmentPlan(); + case "attachment.apply": return store.submit(request.method, async context => { + const plan = await attachmentPlan(); + if (plan.externalServices.length) throw new Error("无法可靠迁移外部进程;请先通过原管理器优雅停服"); + if (plan.upgradeRequired) return launchPlatformUpdate(store, context, plan.targetVersion); + return { attached: true, version: plan.currentVersion, development: plan.development }; + }); + case "updates.check": { + const results = await Promise.allSettled([attachmentPlan(), modulePlan(), bdsUpdate({ id: "check", phase: () => {} }, true)]); + return Object.fromEntries(results.map((result, index) => [["platform", "modules", "bds"][index], result.status === "fulfilled" ? result.value : { error: String(result.reason) }])); + } + case "updates.run": { + if (p.kind === "platform") return store.submit(request.method, async context => { const plan = await attachmentPlan(); return plan.upgradeRequired ? launchPlatformUpdate(store, context, plan.targetVersion) : { upToDate: true, version: plan.currentVersion }; }); + if (p.kind === "modules") return store.submit(request.method, context => withStoppedServices(context, [...START_ORDER], async () => { const result = await applyModulePlan(Array.isArray(p.ids) ? p.ids.map(identifier) : []); await rebuildModules(); return result; }, true, "modules")); + if (p.kind === "bds") return store.submit(request.method, context => withStoppedServices(context, [...START_ORDER], () => bdsUpdate(context), true, "bds")); + throw new Error("未知更新类型"); + } + case "deployment.create": { + if (p.acceptEula !== true) throw new Error("创建部署前必须确认 BDS 使用条款"); + return store.submit(request.method, async context => { + if (isRuntimeInitialized()) throw new Error("目录已经初始化,不覆盖已有部署"); + const ports = { db: deploymentPort(p.dbPort, 3001), bds: deploymentPort(p.bdsPort, 19132), bds6: deploymentPort(p.bdsPort6, 19133) }; + context.phase("preflight", "检查目录、磁盘空间和 TCP/UDP 端口"); await deploymentPreflight(ports); + context.phase("prepare"); ensureCoreConfigs(ROOT, ["db_config", "qq_config", "bds_updater", "permissions"]); + const updater = path.join(ROOT, "configs", "bds_updater.json"); + atomicJson(updater, { ...readJson(updater, {}), bds_path: "BDS", backup_dir: "backups" }); + const dbFile = path.join(ROOT, "configs", "db_config.json"); atomicJson(dbFile, { ...readJson(dbFile, {}), db_port: ports.db }); + fs.mkdirSync(path.join(ROOT, "modules", "packages"), { recursive: true }); + await bdsUpdate(context); + const properties = path.join(ROOT, "BDS", "server.properties"); + let text = fs.readFileSync(properties, "utf8"); + for (const [key, port] of [["server-port", ports.bds], ["server-portv6", ports.bds6]] as const) text = text.replace(new RegExp(`^${key}=.*$`, "m"), `${key}=${port}`); + fs.writeFileSync(properties, text); + await rebuildModules(); + refreshServices(); context.phase("start"); + const started: ServiceName[] = []; + try { for (const name of START_ORDER) { const result = await services[name].start(); if (result.status !== "skipped") started.push(name); } await verifyRunning(started); } + catch (error) { for (const name of [...START_ORDER].reverse()) await services[name].stop(false, false).catch(() => {}); throw error; } + atomicJson(path.join(ROOT, "configs", "runtime.json"), { ...readJson(path.join(ROOT, "configs", "runtime.json"), {}), initialized_at: new Date().toISOString() }); + return { created: true, rows: await queryServicesRuntimeLocal() }; + }); + } + case "backups.list": { const directory = path.join(ROOT, ".sfmc", "backups"); return { backups: fs.existsSync(directory) ? fs.readdirSync(directory).filter(id => fs.existsSync(path.join(directory, id, "manifest.json"))) : [] }; } + case "backups.restore": { + if (p.confirmDataLoss !== true) throw new Error("恢复数据需要明确确认丢弃备份之后的变更"); + return store.submit(request.method, context => withStoppedServices(context, [...START_ORDER], () => restoreSnapshot(identifier(p.id), context.phase))); + } + case "startup.plan": return startupPlan(); + default: throw Object.assign(new Error(`不支持的管理方法: ${request.method}`), { code: "unsupported" }); + } +} diff --git a/packages/cli/src/management/startup.ts b/packages/cli/src/management/startup.ts new file mode 100644 index 00000000..a29d6094 --- /dev/null +++ b/packages/cli/src/management/startup.ts @@ -0,0 +1,28 @@ +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; +import { createHash } from "node:crypto"; +import { ROOT } from "../runtime.js"; + +/** 生成可审阅的管理员安装入口,密码通过系统 Credential API 提交。 */ +export function startupPlan() { + const stable = path.join(ROOT, ".sfmc", "runtime", "launcher.mjs"); + const entry = fs.existsSync(stable) ? stable : process.env.SFMC_DAEMON_ENTRY; + if (!entry) throw new Error("无法确定守护进程入口"); + const id = createHash("sha256").update(path.resolve(ROOT)).digest("hex").slice(0, 16); + let account = process.env.USERNAME ?? process.env.USER ?? ""; + try { account = os.userInfo().username; } catch { /* 权限受限宿主仍可生成由管理员填写账号的安装脚本 */ } + if (process.platform === "win32") { + const quote = (text: string) => "'" + text.replace(/'/g, "''") + "'"; + const wrapper = path.join(ROOT, ".sfmc", "runtime", `sfmc${id}.exe`); + const xml = wrapper.replace(/\.exe$/, ".xml"); + const escapeXml = (text: string) => text.replace(/&/g, "&").replace(/sfmc${id}SFMC ${id}${escapeXml(process.execPath)}"${escapeXml(entry)}" --daemon --autostart${escapeXml(process.execPath)}"${escapeXml(entry)}" daemon stop${escapeXml(ROOT)}90sec`; + return { filename: "install-sfmc-service.ps1", script: `#Requires -RunAsAdministrator\n$ErrorActionPreference = 'Stop'\n$wrapper = ${quote(wrapper)}\nif (Get-Service -Name ${quote(`sfmc${id}`)} -ErrorAction SilentlyContinue) { throw '系统服务已存在,不重复安装' }\nCopy-Item -LiteralPath ${quote(path.join(ROOT, ".sfmc", "runtime", "WinSW-x64.exe"))} -Destination $wrapper\n${quote(body)} | Set-Content -LiteralPath ${quote(xml)} -Encoding UTF8\n$credential = Get-Credential -UserName ${quote(account)} -Message '输入原 Windows 部署账号;密码只交给系统服务管理器'\n& ${quote(process.execPath)} ${quote(entry)} daemon stop\nif ($LASTEXITCODE -ne 0) { throw '原守护进程停止失败' }\nNew-Service -Name ${quote(`sfmc${id}`)} -BinaryPathName ('"' + $wrapper + '"') -Credential $credential -StartupType Automatic\nStart-Service -Name ${quote(`sfmc${id}`)}\n` }; + } + const escapeUnit = (text: string) => text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%"); + if (!account || /[\r\n\0]/.test(account)) throw new Error("无法确定原部署账号"); + const unit = `[Unit]\nDescription=SFMC ${id}\nAfter=network.target\n[Service]\nType=simple\nUser=${account}\nWorkingDirectory="${escapeUnit(ROOT)}"\nEnvironment="SFMC_ROOT=${escapeUnit(ROOT)}"\nExecStart="${escapeUnit(process.execPath)}" "${escapeUnit(entry)}" --daemon --autostart\nExecStop="${escapeUnit(process.execPath)}" "${escapeUnit(entry)}" daemon stop\nRestart=on-failure\nTimeoutStopSec=90\n[Install]\nWantedBy=multi-user.target\n`; + const quote = (text: string) => "'" + text.replace(/'/g, "'\\''") + "'"; + return { filename: "install-sfmc-service.sh", script: `#!/bin/sh\nset -eu\n[ "$(id -u)" = 0 ] || { echo '请以管理员运行此一次性安装脚本'; exit 1; }\nunit=/etc/systemd/system/sfmc-${id}.service\n[ ! -e "$unit" ] || { echo '系统服务已存在,不重复安装'; exit 1; }\n${quote(process.execPath)} ${quote(entry)} daemon stop\nprintf '%s' '${Buffer.from(unit).toString("base64")}' | base64 -d > "$unit"\nsystemctl daemon-reload\nsystemctl enable --now sfmc-${id}.service\n` }; +} diff --git a/packages/cli/src/management/stdio.ts b/packages/cli/src/management/stdio.ts new file mode 100644 index 00000000..5857cb6a --- /dev/null +++ b/packages/cli/src/management/stdio.ts @@ -0,0 +1,74 @@ +import readline from "node:readline"; +import path from "node:path"; +import fs from "node:fs"; +import { toManagementError, type ManagementRequest } from "@sfmc-bds/management"; + +/** 首帧确定实例目录,之后才加载含 ROOT 单例的 CLI 代码。 */ +export async function runManagementStdio() { + const output = process.stdout.write.bind(process.stdout); + for (const key of ["log", "info", "warn", "error", "debug"] as const) console[key] = (...args: unknown[]) => { process.stderr.write(args.map(value => typeof value === "string" ? value : JSON.stringify(value)).join(" ") + "\n"); }; + const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + let ready = false; + let dispose: (() => void) | undefined; + let forwarding: (() => void) | undefined; + let legacy = false; + const pending = new Set>(); + const processLine = async (line: string) => { + let request: ManagementRequest = { id: "invalid", method: "invalid" }; + try { + if (Buffer.byteLength(line) > 4 * 1024 * 1024) throw new Error("管理帧过大"); + request = JSON.parse(line) as ManagementRequest; + if (!request || typeof request.id !== "string" || request.id.length > 128 || typeof request.method !== "string" || request.method.length > 128) throw new Error("非法管理帧"); + if (!ready) { + if (request.method !== "handshake") throw new Error("首个请求必须为握手"); + const params = request.params as { root?: unknown } | undefined; + if (typeof params?.root !== "string" || !path.isAbsolute(params.root) || !fs.statSync(params.root).isDirectory()) throw new Error("需要有效的实例绝对目录"); + process.env.SFMC_ROOT = path.resolve(params.root); ready = true; + } else if (request.method === "handshake" && (request.params as { root?: string } | undefined)?.root && path.resolve((request.params as { root: string }).root) !== process.env.SFMC_ROOT) { + throw new Error("一个管理会话只能连接一个实例"); + } + const client = await import("../daemon/client.js"); + if (!forwarding) forwarding = client.onDaemonManagementEvent(event => output(JSON.stringify(event) + "\n")); + if (!legacy) { + try { + const result = await client.callDaemon("management", { request }); + if (result.kind !== "management") throw new Error("旧守护进程没有管理入口"); + output(JSON.stringify(result.response) + "\n"); + if (request.method === "events.subscribe" && !dispose) dispose = await client.ensureDaemonSubscription(); + return; + } catch (error) { + if (request.method !== "handshake" || !/unknown method|管理入口/.test(error instanceof Error ? error.message : String(error))) throw error; + legacy = true; + try { process.env.SFMC_PLATFORM_VERSION = (JSON.parse(fs.readFileSync(path.join(process.env.SFMC_ROOT!, "node_modules", "@sfmc-bds", "sfmc", "package.json"), "utf8")) as { version: string }).version; } catch { process.env.SFMC_PLATFORM_VERSION = "unknown"; } + } + } + // 旧部署只开放接入规划、状态和持久化任务;升级交给独立维护进程。 + const backend = await import("./server.js"); + let value: unknown; + if (request.method === "handshake") { + value = { ...(await backend.dispatchManagement(request) as object), capabilities: ["services", "logs", "operations", "updates"], legacy: true }; + } else if (request.method === "services.list") { + const result = await client.callDaemon("status"); + if (result.kind !== "status") throw new Error("旧部署无法提供服务状态"); + value = { rows: result.rows }; + } else if (request.method === "attachment.plan" || request.method === "attachment.apply") { + const status = await client.callDaemon("status"); + if (status.kind !== "status") throw new Error("旧部署无法提供服务状态"); + const running = status.rows.filter(row => row.running).map(row => row.name); + if (request.method === "attachment.apply" && running.length) throw Object.assign(new Error("旧守护进程缺少可确认的优雅维护接口;请先通过原管理器停止服务,再执行接入升级"), { code: "conflict" }); + const plan = await backend.dispatchManagement(request); + value = request.method === "attachment.plan" ? { ...plan as object, externalServices: running } : plan; + } else if (["events.subscribe", "logs.tail", "operations.list", "operations.get", "backups.list"].includes(request.method)) value = await backend.dispatchManagement(request); + else throw Object.assign(new Error("旧部署需先通过接入流程升级平台,再使用完整管理功能"), { code: "unsupported" }); + output(JSON.stringify({ type: "res", id: request.id, ok: true, result: value }) + "\n"); + } catch (error) { output(JSON.stringify({ type: "res", id: request?.id ?? "invalid", ok: false, error: toManagementError(error) }) + "\n"); } + }; + lines.on("line", line => { + if (pending.size >= 64) { process.stderr.write("管理请求积压,关闭当前会话\n"); lines.close(); return; } + const action = processLine(line).catch(error => { process.stderr.write(String(error) + "\n"); }); + pending.add(action); void action.finally(() => pending.delete(action)); + }); + await new Promise(resolve => lines.once("close", resolve)); + await Promise.allSettled(pending); dispose?.(); forwarding?.(); + if (ready) (await import("../daemon/client.js")).disconnectDaemonClient(); +} diff --git a/packages/cli/src/management/tasks.ts b/packages/cli/src/management/tasks.ts new file mode 100644 index 00000000..900a2fd1 --- /dev/null +++ b/packages/cli/src/management/tasks.ts @@ -0,0 +1,73 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { EventEmitter } from "node:events"; +import { withMaintenanceLock } from "@sfmc-bds/management/node"; +import { toManagementError, type OperationRecord } from "@sfmc-bds/management"; + +export function atomicJson(file: string, value: unknown) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const temp = `${file}.${randomUUID()}.tmp`; + fs.writeFileSync(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); + fs.renameSync(temp, file); +} +export type TaskContext = { phase: (name: string, message?: string) => void; id: string }; + +/** 任务属于守护进程而非 SSH 会话;异常恢复只标记,不重放写入。 */ +export class TaskStore extends EventEmitter { + private queue: Promise = Promise.resolve(); + private directory: string; + constructor(private root: string) { + super(); + this.directory = path.join(root, ".sfmc", "operations"); + fs.mkdirSync(this.directory, { recursive: true }); + for (const record of this.list()) { + if (record.status === "queued" || record.status === "running") { + const pid = (record as OperationRecord & { workerPid?: number }).workerPid; + let active = false; + if (pid) { try { process.kill(pid, 0); active = true; } catch { /* 已退出 */ } } + if (!active) { record.status = "interrupted"; record.error = { code: "unavailable", message: "执行进程异常退出;请检查备份与实际状态,任务不会自动重发" }; this.save(record); } + } + } + } + get(id: string): OperationRecord { + if (!/^[a-zA-Z0-9-]{1,80}$/.test(id)) throw new Error("非法任务编号"); + return JSON.parse(fs.readFileSync(path.join(this.directory, `${id}.json`), "utf8")) as OperationRecord; + } + list(): OperationRecord[] { + return fs.readdirSync(this.directory).filter(name => /^[a-f0-9-]{36}\.json$/.test(name)).flatMap(name => { + try { const record = JSON.parse(fs.readFileSync(path.join(this.directory, name), "utf8")) as OperationRecord; return typeof record.createdAt === "string" && Array.isArray(record.phases) ? [record] : []; } catch { return []; } + }).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 200); + } + save(record: OperationRecord) { + record.updatedAt = new Date().toISOString(); + atomicJson(path.join(this.directory, `${record.id}.json`), record); + this.emit("operation", record); + } + submit(kind: string, action: (context: TaskContext) => Promise) { + const record: OperationRecord = { id: randomUUID(), kind, title: kind, status: "queued", phases: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; + this.save(record); + this.queue = this.queue.catch(() => {}).then(async () => { + try { + await withMaintenanceLock(this.root, async () => { + record.status = "running"; this.save(record); + const phase = (name: string, message?: string) => { + const previous = record.phases.at(-1); + if (previous?.status === "running") { previous.status = "done"; previous.finishedAt = new Date().toISOString(); } + record.phases.push({ name, status: "running", startedAt: new Date().toISOString(), ...(message ? { message } : {}) }); this.save(record); + }; + record.result = await action({ phase, id: record.id }); + // 独立维护进程接管记录之后,守护进程不再覆盖其状态。 + if ((record.result as { detached?: boolean } | undefined)?.detached) return; + const last = record.phases.at(-1); if (last) { last.status = "done"; last.finishedAt = new Date().toISOString(); } + record.status = "succeeded"; this.save(record); + }); + } catch (error) { + record.status = "failed"; record.error = toManagementError(error); + const last = record.phases.at(-1); if (last) last.status = "failed"; + this.save(record); + } + }); + return { operationId: record.id }; + } +} diff --git a/packages/cli/src/module-commands.ts b/packages/cli/src/module-commands.ts index f8ad7029..89290dd8 100644 --- a/packages/cli/src/module-commands.ts +++ b/packages/cli/src/module-commands.ts @@ -6,10 +6,6 @@ * 子命令通道门禁见 command-surface.ts(dispatch 前由 main/repl 判定)。 */ -import fs from "node:fs/promises"; -import { existsSync, lstatSync, readdirSync, statSync } from "node:fs"; -import path from "node:path"; -import { spawn } from "node:child_process"; import { configPath, modulePath, @@ -19,15 +15,13 @@ import { type DBConfig, type ModuleLock, } from "@sfmc-bds/sdk/node/config"; -import { - isDevAccentModuleSub, - listVisibleModuleSubs, - type CommandMode, -} from "./command-surface.js"; +import { spawn } from "node:child_process"; +import { existsSync, lstatSync, readdirSync, statSync } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; import { failResult, okResult, type CliResult } from "./cli-result.js"; +import { isDevAccentModuleSub, listVisibleModuleSubs, type CommandMode } from "./command-surface.js"; import { t } from "./i18n/index.js"; -import { c } from "./theme.js"; -import { ROOT, resolveFetchModule } from "./runtime.js"; import { dirFingerprint, shouldSkipModuleEntry } from "./module-fingerprint.js"; import { applyLockEnabled, @@ -36,12 +30,10 @@ import { type ToggleCandidate, type ToggleNotify, } from "./module-toggle.js"; -import { - DEFAULT_REGISTRY_REPO, - DEFAULT_REGISTRY_TAG, - findUnknownModules, - resolveRegistryIndex, -} from "./registry.js"; +import { DEFAULT_REGISTRY_REPO, DEFAULT_REGISTRY_TAG, findUnknownModules, resolveRegistryIndex } from "./registry.js"; +import { ROOT, resolveFetchModule } from "./runtime.js"; +import { c } from "./theme.js"; +import { isRetiredPlatformModule } from "@sfmc-bds/sdk/contracts"; /** 顶层命令名(主名 + 短别名),供帮助 / 补全 / 分发共用。 */ export const MODULE_CMD_NAMES = ["module", "mod"] as const; @@ -54,7 +46,7 @@ export function isModuleCommand(cmd: string | undefined): cmd is ModuleCmdName { } /** 开发者样式子命令(蓝标,非门禁)。 */ -export const DEV_ACCENT_MODULE_SUBCOMMANDS = ["build", "reload"] as const; +export const DEV_ACCENT_MODULE_SUBCOMMANDS = ["build", "reload", "submit"] as const; /** 当前通道下可见的 module 子命令(自动补全 / usage)。 */ export function getVisibleModuleSubcommands(mode: CommandMode): readonly string[] { @@ -170,11 +162,10 @@ export async function cmdModuleList(_args: string[]): Promise { if (installed.length === 0) { return c.dim(t("mod.noneInstalled", { dir: modulesDir() })); } - const lock = - (readJson(modulePath(path.join(ROOT, "modules"), "module-lock.json")) ?? { - version: 1, - modules: {}, - }) as ModuleLock; + const lock = (readJson(modulePath(path.join(ROOT, "modules"), "module-lock.json")) ?? { + version: 1, + modules: {}, + }) as ModuleLock; const ids = installed.map((m) => m.id); const unknown = new Set(await findUnknownModules(ids)); const lines: string[] = [c.bold(`\n${t("mod.list.title")}`), c.dim(` ${modulesDir()}`)]; @@ -248,9 +239,7 @@ export async function cmdModuleSearch(args: string[]): Promise { lines.push(c.dim(` ${"id".padEnd(28)}${"source".padEnd(40)}local`)); for (const id of ids) { const e = index[id]!; - const src = e.npm - ? `npm:${e.npm}${e.version ? `@${e.version}` : ""}` - : `github:${e.repo}@${e.tag}`; + const src = e.npm ? `npm:${e.npm}${e.version ? `@${e.version}` : ""}` : `github:${e.repo}@${e.tag}`; const local = installed.has(id) ? c.green("●") : c.dim("○"); lines.push(` ${local} ${id.padEnd(26)}${src.padEnd(40)}${installed.has(id) ? "installed" : ""}`); } @@ -286,9 +275,7 @@ export async function cmdModuleSearch(args: string[]): Promise { lines.push(` source : github:${entry.repo}@${entry.tag}`); lines.push(` github : https://github.com/${entry.repo}/tree/${entry.tag}`); } - lines.push( - ` local : ${isInstalled ? c.green(`installed @ ${installedPath}`) : c.dim("not installed")}` - ); + lines.push(` local : ${isInstalled ? c.green(`installed @ ${installedPath}`) : c.dim("not installed")}`); if (!isInstalled) { lines.push(c.dim(`\n install: sfmc mod install ${query}`)); @@ -316,7 +303,8 @@ export async function cmdModuleInfo(args: string[]): Promise { lines.push(` schemaVer : ${m.manifest.schemaVersion ?? "(none)"}`); lines.push(` routes : ${m.manifest.routes?.length ?? 0}`); if (m.manifest.routes?.length) { - for (const r of m.manifest.routes) lines.push(c.dim(` ${r.method.padEnd(7)} ${r.path.padEnd(36)} ${r.handler}`)); + for (const r of m.manifest.routes) + lines.push(c.dim(` ${r.method.padEnd(7)} ${r.path.padEnd(36)} ${r.handler}`)); } lines.push(` migrations : ${m.manifest.migrations?.length ?? 0}`); if (m.manifest.migrations?.length) { @@ -393,11 +381,10 @@ function buildToggleCandidates(installed: InstalledModule[]): ToggleCandidate[] function writeLocalModuleEnabled(logicalId: string, enabled: boolean): void { const lockPath = moduleLockPath(); - const lock = - (readJson(lockPath) ?? { - version: 1, - modules: {}, - }) as ModuleLock; + const lock = (readJson(lockPath) ?? { + version: 1, + modules: {}, + }) as ModuleLock; applyLockEnabled(lock, logicalId, enabled); writeJson(lockPath, lock); } @@ -439,6 +426,8 @@ async function notifyDbToggle(logicalId: string, action: "enable" | "disable"): async function cmdModuleToggle(query: string, action: "enable" | "disable"): Promise { const installed = await scanInstalled(); const target = resolveToggleTarget(query, buildToggleCandidates(installed)); + if (action === "enable" && target && isRetiredPlatformModule(target.logicalId)) return failResult("monitor 已收编至平台,无需启用;请更新平台行为包。"); + if (action === "enable" && target && isRetiredPlatformModule(target.logicalId)) return failResult("monitor 已收编至平台,无需启用;请更新平台行为包。"); if (!target) { return failResult(c.red(t("mod.notInstalled", { id: query }))); } @@ -450,9 +439,7 @@ async function cmdModuleToggle(query: string, action: "enable" | "disable"): Pro try { writeLocalModuleEnabled(target.logicalId, enabled); } catch (err) { - return failResult( - c.red(t("mod.toggleLockFailed", { id: target.logicalId, message: (err as Error).message })) - ); + return failResult(c.red(t("mod.toggleLockFailed", { id: target.logicalId, message: (err as Error).message }))); } const notify = await notifyDbToggle(target.logicalId, action); @@ -469,6 +456,7 @@ async function cmdModuleToggle(query: string, action: "enable" | "disable"): Pro export async function cmdModuleEnable(args: string[]): Promise { const id = args[0]; + if (id && isRetiredPlatformModule(id)) return failResult("monitor 已收编至平台,无需安装或启用;请更新平台行为包。"); if (!id) return failResult(c.yellow(t("mod.enable.usage"))); return cmdModuleToggle(id, "enable"); } @@ -628,11 +616,22 @@ function parseFlags(args: string[]): InstallFlags { * 作者向 test/watch/publish 已迁至 VS Code 扩展与 @sfmc-bds/devkit。 */ export async function dispatchModuleCommand(sub: string | undefined, args: string[]): Promise { + if (sub && ["install", "uninstall", "remove", "enable", "disable", "update", "pin", "build", "reload"].includes(sub) && !args.includes("--check")) { + const { withMaintenanceLock } = await import("@sfmc-bds/management/node"); + return withMaintenanceLock(ROOT, () => dispatchModuleCommandUnlocked(sub, args)); + } + return dispatchModuleCommandUnlocked(sub, args); +} +async function dispatchModuleCommandUnlocked(sub: string | undefined, args: string[]): Promise { switch (sub) { case "list": return cmdModuleList(args); case "search": return cmdModuleSearch(args); + case "submit": { + const { runRegistrySubmitCommand } = await import("@sfmc-bds/devkit"); + return runRegistrySubmitCommand(args); + } case "install": return cmdModuleInstall(args); case "uninstall": diff --git a/packages/cli/src/pack-lifecycle.ts b/packages/cli/src/pack-lifecycle.ts index b23757df..1a263461 100644 --- a/packages/cli/src/pack-lifecycle.ts +++ b/packages/cli/src/pack-lifecycle.ts @@ -23,6 +23,7 @@ import { existsSync, readFileSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { pushLog } from "./logs.js"; +import { isRetiredPlatformModule } from "@sfmc-bds/sdk/contracts"; import { dirFingerprint } from "./module-fingerprint.js"; import { failResult, okResult, type CliResult } from "./cli-result.js"; import { t } from "./i18n/index.js"; @@ -310,6 +311,7 @@ export function deployedCatalogPath(bdsRoot: string, levelName: string): string /** 解析启用状态:lock 优先,否则 catalog.enabledByDefault(缺省 true↔!==false),未收录模块 false */ function isModuleEnabled(logicalId: string, lock: ModuleLock, catalogDefaults: Map): boolean { + if (isRetiredPlatformModule(logicalId)) return false; const st = lock.modules?.[logicalId]; if (st && typeof st.enabled === "boolean") return st.enabled; return catalogDefaults.get(logicalId) ?? false; diff --git a/packages/cli/src/runtime-main.ts b/packages/cli/src/runtime-main.ts new file mode 100644 index 00000000..5b7cb862 --- /dev/null +++ b/packages/cli/src/runtime-main.ts @@ -0,0 +1,203 @@ +#!/usr/bin/env node +import process from "node:process"; +import { mapPacksSubAlias, parseGlobalArgv } from "./argv-parse.js"; +import { gateModuleSub, gatePacksSub, gateTopLevel } from "./cli-gate.js"; +import { resolveModuleTopShorthand } from "./command-surface.js"; +import { cmdRestart, cmdStart, cmdStartAll, cmdStatus, cmdStop, cmdStopAll, cmdUpdate } from "./commands.js"; +import { cmdDebug } from "./debug-command.js"; +import { initLocale, stripLangArgs, t } from "./i18n/index.js"; +import { cmdLocale } from "./locale-command.js"; +import { dispatchModuleCommand, isModuleCommand, scanAndWarnUnknown } from "./module-commands.js"; +import { getHelp, playWelcomeAnimation, startRepl } from "./repl.js"; +import { ROOT } from "./runtime.js"; +import { c } from "./theme.js"; +import { dispatchPacksCommand, isPacksCommand } from "./world-packs.js"; + +const MODE = "argv" as const; + +async function printVersion(): Promise { + await playWelcomeAnimation(); +} + +function printUsage(): void { + console.log(`${getHelp("argv")}`); +} + +function deny(msg: string): never { + console.log(msg); + process.exit(1); +} + +async function main(): Promise { + const stripped = stripLangArgs(process.argv.slice(2)); + initLocale({ root: ROOT, flag: stripped.lang }); + let args = stripped.args; + + /* 内部入口:后台守护进程(不解析用户命令) */ + if (args[0] === "--daemon") { + const { runDaemonServer } = await import("./daemon/server.js"); + await runDaemonServer(); + return; + } + + const { packsMode, args: parsedArgs } = parseGlobalArgv(args); + args = parsedArgs; + + if (args.length === 0 && !packsMode) { + const { isRuntimeInitialized } = await import("./runtime.js"); + if (!isRuntimeInitialized()) { + const { runWizard } = await import("./wizard.js"); + await runWizard(); + const { refreshServices } = await import("./services.js"); + refreshServices(); + } + const warn = await scanAndWarnUnknown(); + if (warn) console.log(warn); + await startRepl(); + return; + } + + if (packsMode) { + const [subRaw, ...subRest] = args; + const sub = mapPacksSubAlias(subRaw); + const g = gatePacksSub(sub, MODE); + if (g) deny(g); + console.log(await dispatchPacksCommand(sub, subRest)); + process.exit(0); + } + + const [cmd, ...rest] = args; + + if (cmd && !["--help", "-h", "--version", "-v"].includes(cmd)) { + const topGate = gateTopLevel(cmd, MODE); + if (topGate) deny(topGate); + } + + const moduleSub = resolveModuleTopShorthand(cmd); + if (moduleSub) { + const g = gateModuleSub(moduleSub, MODE); + if (g) deny(g); + console.log(await dispatchModuleCommand(moduleSub, rest)); + process.exit(0); + } + + switch (cmd) { + case "--help": + case "-h": + case "help": + printUsage(); + break; + case "--version": + case "-v": + await printVersion(); + break; + case "locale": + case "lang": + console.log(cmdLocale(rest)); + break; + case "debug": + console.log(await cmdDebug(rest)); + break; + case "ui": { + const { cmdUi } = await import("./ui-command.js"); + console.log(await cmdUi(rest, { block: true })); + break; + } + case "daemon": { + const sub = rest[0]; + if (sub === "stop") { + const { shutdownDaemon } = await import("./daemon/client.js"); + const err = await shutdownDaemon(); + console.log(err ? c.red(err) : c.dim(t("daemon.stopped"))); + } else if (sub === "status" || !sub) { + const { readDaemonMeta } = await import("./daemon/paths.js"); + const { isProcessAlive } = await import("@sfmc-bds/bds-tools/process-probe"); + const meta = readDaemonMeta(); + if (!meta || !(await isProcessAlive(meta.pid))) { + console.log(c.dim(t("daemon.notRunning"))); + } else { + console.log(c.green(t("daemon.running", { pid: String(meta.pid) }))); + } + } else { + console.log(c.yellow(t("daemon.usage"))); + } + break; + } + case "status": + console.log(await cmdStatus()); + break; + case "logs": + case "log": + /* channel=repl:正常由 gateTopLevel 拦截;此处兜底 */ + deny(gateTopLevel("logs", MODE) ?? c.yellow(t("cli.replOnly", { cmd: "logs" }))); + break; + case "start": + if (rest[0] === "-all" || rest[0] === "all" || rest[0] === "--all") { + console.log(await cmdStartAll()); + } else if (rest[0]) { + console.log(await cmdStart(rest[0])); + } else { + console.log(c.yellow(t("svc.start.usage"))); + } + break; + case "stop": + if (rest[0] === "-all" || rest[0] === "all" || rest[0] === "--all") { + console.log(await cmdStopAll()); + } else if (rest[0]) { + console.log(await cmdStop(rest[0])); + } else { + console.log(c.yellow(t("svc.stop.usage"))); + } + break; + case "restart": + if (rest[0] === "-all" || rest[0] === "all" || rest[0] === "--all") { + await cmdStopAll(); + console.log(await cmdStartAll()); + } else if (rest[0]) { + console.log(await cmdRestart(rest[0])); + } else { + console.log(c.yellow(t("svc.restart.usage"))); + } + break; + case "update": + console.log(await cmdUpdate(rest)); + break; + case "packs": + case "addon": { + const [sub, ...subRest] = rest; + const g = gatePacksSub(sub, MODE); + if (g) deny(g); + console.log(await dispatchPacksCommand(sub, subRest)); + break; + } + case "init": { + const { runWizard } = await import("./wizard.js"); + await runWizard(); + break; + } + default: + if (isModuleCommand(cmd)) { + const [sub, ...subRest] = rest; + const g = gateModuleSub(sub, MODE); + if (g) deny(g); + console.log(await dispatchModuleCommand(sub, subRest)); + break; + } + if (isPacksCommand(cmd)) { + const [sub, ...subRest] = rest; + const g = gatePacksSub(sub, MODE); + if (g) deny(g); + console.log(await dispatchPacksCommand(sub, subRest)); + break; + } + console.log(c.red(t("common.unknownCommand", { cmd: cmd ?? "" }))); + printUsage(); + process.exit(1); + } + process.exit(0); +} + +main().catch((err) => { + console.error(c.red(err?.message ? t("common.error", { message: err.message }) : t("common.fatal"))); + process.exit(1); +}); diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index d00b4c3f..7b376d86 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -240,7 +240,7 @@ export function resolveSdkPackageRoot(): string { } function nodeBinary(): string { - return process.execPath; + return process.env.SFMC_NODE_BINARY ?? process.execPath; } function serviceChildEnv(service: ServiceId, optsEnv?: NodeJS.ProcessEnv | null) { diff --git a/packages/cli/src/services.ts b/packages/cli/src/services.ts index fb955697..a7d1bb50 100644 --- a/packages/cli/src/services.ts +++ b/packages/cli/src/services.ts @@ -390,7 +390,7 @@ class Service { * - false 或不传:`stop all`、`restart -all`、守护进程 shutdown / SIGTERM(经 stopAll)。 * 不按镜像名或脚本名杀外部进程,避免误杀同机其他 Bedrock、db、qq。 */ - async stop(external = false): Promise { + async stop(external = false, forceOnTimeout = true): Promise { this.manualStop = true; if (this.restartTimer) { clearTimeout(this.restartTimer); @@ -412,7 +412,7 @@ class Service { } const managed = Boolean(this.proc && this.running); - if (managed) await this.stopManagedChild(); + if (managed) await this.stopManagedChild(forceOnTimeout); /* db / qq:仅单服务停止时,托管停完后仍按脚本再扫一遍,清掉同机上的外部实例 */ if (external && (this.name === "db" || this.name === "qq")) { @@ -430,7 +430,7 @@ class Service { * 优雅停止当前托管子进程(stdin stop 或 SIGTERM,超时后 SIGKILL)。 * 使用场景:Service.stop 确认本进程仍持有子进程时调用;逻辑与原先 stop 主体相同。 */ - private async stopManagedChild(): Promise { + private async stopManagedChild(forceOnTimeout = true): Promise { if (!this.proc || !this.running) return; this.events.emit("output", "stopping...", "info"); @@ -440,8 +440,12 @@ class Service { this.proc.kill("SIGTERM"); } - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const timeout = setTimeout(() => { + if (!forceOnTimeout && this.proc) { + reject(new Error(`${this.name} 未在超时内优雅退出;已中止维护,不强制结束进程`)); + return; + } if (this.proc) { this.events.emit("output", "force kill", "error"); try { diff --git a/packages/cli/src/world-packs.ts b/packages/cli/src/world-packs.ts index 4d324f67..927f7e9e 100644 --- a/packages/cli/src/world-packs.ts +++ b/packages/cli/src/world-packs.ts @@ -27,6 +27,7 @@ import { import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { withMaintenanceLock } from "@sfmc-bds/management/node"; import { getLocale, t } from "./i18n/index.js"; import { pushLog, type LogLevel } from "./logs.js"; import { BP_NAME, RP_NAME, resolveBdsContext } from "./pack-lifecycle.js"; @@ -923,6 +924,12 @@ async function cmdDoctor(args: string[] = []): Promise { } export async function dispatchPacksCommand(sub: string | undefined, args: string[]): Promise { + if (["bind", "unbind", "update", "enable", "disable", "install", "uninstall", "import", "repair", "bump"].includes(sub ?? "") && !args.includes("--dry-run")) { + return withMaintenanceLock(ROOT, () => dispatchPacksCommandUnlocked(sub, args)); + } + return dispatchPacksCommandUnlocked(sub, args); +} +async function dispatchPacksCommandUnlocked(sub: string | undefined, args: string[]): Promise { const verb = (sub ?? "").toLowerCase(); if (!verb || verb === "help" || verb === "-h" || verb === "--help") { return packsUsage(); diff --git a/packages/create-module/templates/base/README.md b/packages/create-module/templates/base/README.md index 563526cf..6d26cbe7 100644 --- a/packages/create-module/templates/base/README.md +++ b/packages/create-module/templates/base/README.md @@ -19,9 +19,22 @@ pnpm run lint 1. 扩展 **SFMC: Link to SFMC Root**(或 `sfmc mod install {{id}} --from dir:. --link`) 2. **SFMC: Start Watch** / `sfmc mod reload` -## 脚本 +## 发布与收录 -| 命令 | 作用 | -| ---------------------------------- | ------------------------------ | +先填写 `package.json` 中真实的功能介绍、HTTPS 源码仓地址和 SDK peerDependencies; +展示名称默认读取清单的 `name`,可在 `sfmc.registry.json` 中补充名称、介绍、分类、标签和公开作者署名。 + +```bash +pnpm publish --access public +sfmc mod submit --dry-run +sfmc mod submit +``` + +提交需要 `gh auth login` 或 `GH_TOKEN`;只提交 `modules/.json`,合并后由索引仓生成 `index.json`。 + +## 脚本列表 + +| 命令 | 作用 | +| ---------------- | ----------------- | | `pnpm run build` | tsc --noEmit | | `pnpm run lint` | ESLint / Prettier | diff --git a/packages/create-module/templates/base/package.json b/packages/create-module/templates/base/package.json index a5f0c7c3..efe0e196 100644 --- a/packages/create-module/templates/base/package.json +++ b/packages/create-module/templates/base/package.json @@ -12,6 +12,7 @@ "files": [ "sapi", "configs-default", + "sfmc.registry.json", "README.md", "LICENSE" ], diff --git a/packages/create-module/templates/base/sfmc.registry.json b/packages/create-module/templates/base/sfmc.registry.json new file mode 100644 index 00000000..332f4e95 --- /dev/null +++ b/packages/create-module/templates/base/sfmc.registry.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://cdn.jsdelivr.net/gh/DogeLakeDev/ScriptsForMinecraftServer@main/packages/devkit/schemas/registry-author.schema.json", + "category": "utility", + "tags": [] +} diff --git a/packages/db-server/src/domain/metrics.ts b/packages/db-server/src/domain/metrics.ts new file mode 100644 index 00000000..07ee0b90 --- /dev/null +++ b/packages/db-server/src/domain/metrics.ts @@ -0,0 +1,93 @@ +/** 运行指标的受控校验与有界历史;只记录平台上报,不接受任意表名或 SQL。 */ +import type { QueryFn } from "../lib/sqlite.js"; + +export interface MetricsSample { + recordedAt: number; + bootId: string; + tps: number | null; + onlineCount: number; + entities: Record; + entitiesUpdatedAt: number | null; + chunkEstimate: number | null; +} +const DIMENSIONS = ["minecraft:overworld", "minecraft:nether", "minecraft:the_end"]; +const count = (value: unknown) => + value === null || (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 100_000_000); + +export function parseMetrics(data: Record, now: number): MetricsSample | null { + if (!Array.isArray(data.players) || data.players.length > 1000) return null; + if ( + typeof data.bootId !== "string" || + !/^[a-zA-Z0-9-]{1,96}$/.test(data.bootId) || + !( + data.tps === null || + (typeof data.tps === "number" && Number.isFinite(data.tps) && data.tps >= 0 && data.tps <= 20) + ) || + !count(data.chunkEstimate) || + typeof data.onlineCount !== "number" || + data.onlineCount !== (data.players as unknown[]).length || + !data.entities || + typeof data.entities !== "object" || + Array.isArray(data.entities) || + !( + data.entitiesUpdatedAt === null || + (typeof data.entitiesUpdatedAt === "number" && + Number.isSafeInteger(data.entitiesUpdatedAt) && + data.entitiesUpdatedAt > 0 && + data.entitiesUpdatedAt <= now + 2000) + ) + ) + return null; + const raw = data.entities as Record; + if (Object.keys(raw).some((key) => !DIMENSIONS.includes(key)) || DIMENSIONS.some((key) => !count(raw[key]))) + return null; + return { + recordedAt: now, + bootId: data.bootId, + tps: data.tps as number | null, + onlineCount: data.onlineCount, + entities: { ...raw } as Record, + entitiesUpdatedAt: data.entitiesUpdatedAt as number | null, + chunkEstimate: data.chunkEstimate as number | null, + }; +} + +export class MetricsHistory { + private lastWrite = 0; + private lastPrune = 0; + constructor(private query: QueryFn) { + query("CREATE TABLE IF NOT EXISTS sfmc_runtime_metrics (recorded_at INTEGER PRIMARY KEY, sample TEXT NOT NULL)"); + } + append(sample: MetricsSample): void { + // 同一采样窗口去重,防止异常重试造成无界落库。 + if (sample.recordedAt - this.lastWrite < 3000) return; + this.query("INSERT OR REPLACE INTO sfmc_runtime_metrics (recorded_at, sample) VALUES (?, ?)", [ + sample.recordedAt, + JSON.stringify(sample), + ]); + if (sample.recordedAt - this.lastPrune > 60_000) { + this.prune(sample.recordedAt); + this.lastPrune = sample.recordedAt; + } + this.lastWrite = sample.recordedAt; + } + private prune(now: number): void { + this.query("DELETE FROM sfmc_runtime_metrics WHERE recorded_at < ?", [now - 72 * 3600_000]); + this.query( + "DELETE FROM sfmc_runtime_metrics WHERE recorded_at NOT IN (SELECT recorded_at FROM sfmc_runtime_metrics ORDER BY recorded_at DESC LIMIT 60000)" + ); + } + read(now = Date.now()): MetricsSample[] { + const rows = this.query( + "SELECT sample FROM sfmc_runtime_metrics WHERE recorded_at >= ? ORDER BY recorded_at DESC LIMIT 720", + [now - 3600_000] + ) as { sample: string }[]; + return rows.reverse().flatMap((row) => { + try { + return [JSON.parse(row.sample) as MetricsSample]; + } catch { + return []; + } + }); + } +} diff --git a/packages/db-server/src/domain/process-resources.ts b/packages/db-server/src/domain/process-resources.ts new file mode 100644 index 00000000..376fc05f --- /dev/null +++ b/packages/db-server/src/domain/process-resources.ts @@ -0,0 +1,47 @@ +/** 受控进程资源采集:仅允许已核验的数字 PID,失败保留未知。 */ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +const execute = promisify(execFile); +export interface ProcessResources { + pid: number; + running: boolean; + memoryMb: number | null; + cpuSeconds: number | null; +} + +export async function collectProcessResources(pid: number, running: boolean): Promise { + const result: ProcessResources = { pid, running, memoryMb: null, cpuSeconds: null }; + if (!running || !Number.isSafeInteger(pid) || pid <= 0) return result; + if (pid === process.pid) { + const cpu = process.cpuUsage(); + return { + ...result, + memoryMb: Math.round(process.memoryUsage().rss / 1048576), + cpuSeconds: (cpu.user + cpu.system) / 1_000_000, + }; + } + try { + if (process.platform === "win32") { + const script = `$p=Get-Process -Id ${pid} -ErrorAction Stop; @{memoryMb=[Math]::Round($p.WorkingSet64/1MB);cpuSeconds=$p.CPU}|ConvertTo-Json -Compress`; + const { stdout } = await execute("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { + windowsHide: true, + timeout: 2000, + }); + const data = JSON.parse(stdout) as { memoryMb: number; cpuSeconds: number | null }; + if (Number.isFinite(data.memoryMb) && data.memoryMb >= 0) result.memoryMb = data.memoryMb; + if (typeof data.cpuSeconds === "number" && Number.isFinite(data.cpuSeconds) && data.cpuSeconds >= 0) + result.cpuSeconds = data.cpuSeconds; + } else if (process.platform === "linux") { + const { stdout } = await execute("ps", ["-p", String(pid), "-o", "rss=", "-o", "time="], { timeout: 2000 }); + const match = stdout.trim().match(/^(\d+)\s+(?:(\d+)-)?(\d+):(\d+):(\d+)$/); + if (match) { + result.memoryMb = Math.round(Number(match[1]) / 1024); + result.cpuSeconds = + Number(match[2] ?? 0) * 86400 + Number(match[3]) * 3600 + Number(match[4]) * 60 + Number(match[5]); + } + } + } catch { + /* 无权限、进程退出、采集超时均不伪报 0。 */ + } + return result; +} diff --git a/packages/db-server/src/index.ts b/packages/db-server/src/index.ts index 6722c210..60ee03f1 100644 --- a/packages/db-server/src/index.ts +++ b/packages/db-server/src/index.ts @@ -19,6 +19,7 @@ */ import http from "node:http"; +import { PLATFORM_MONITORING_SERVICES, PLATFORM_SERVICE_OWNER } from "@sfmc-bds/sdk/contracts"; import { SQL } from "sql-template-strings"; import { createPlatformTables } from "./db-tables.js"; @@ -255,7 +256,19 @@ function setModuleEnabled(mod: { id: string; canDisable: boolean }, enabled: boo // ── 平台路由(非模块业务) ─────────────────────────────────── const healthRoutes = createHealthRoutes(); -const statusRoutes = createStatusRoutes({}); +// 指标独立落库,避免写入业务模块正在持有的跨请求事务。 +const metricsDb = openDatabase(path.join(path.dirname(env.DB_PATH), "runtime-metrics.sqlite")); +const statusRoutes = createStatusRoutes({ query: createQuery(metricsDb) }); +for (const name of PLATFORM_MONITORING_SERVICES) serviceRegistry.registerHandler(PLATFORM_SERVICE_OWNER, name, async () => { + const response = await fetch(`http://127.0.0.1:${env.PORT}/api/sfmc/metrics`, { signal: AbortSignal.timeout(10_000) }); + if (!response.ok) throw new Error("平台运行指标读取失败"); + const result = await response.json() as { current: { tps: number | null; chunkEstimate: number | null } | null }; + const tps = result.current?.tps ?? null; + const grade = tps === null ? "unknown" : tps >= 19.5 ? "green" : tps >= 15 ? "yellow" : tps >= 10 ? "gold" : "red"; + if (name === "tps.current") return tps; + if (name === "tps.status") return { tps, grade, text: `§7[TPS] §f${tps?.toFixed(2) ?? "未知"} §7/ 20.00` }; + return result.current ? { ...result.current, grade, totalLoadedChunks: result.current.chunkEstimate } : null; +}); const qqBindRoutes = createQqBindRoutes({ query, body, json }); /** 模块 qq-link 的配置文件(非 SDK qq_config) */ diff --git a/packages/db-server/src/manifest-loader.ts b/packages/db-server/src/manifest-loader.ts index 3bdeec2e..82b91eaf 100644 --- a/packages/db-server/src/manifest-loader.ts +++ b/packages/db-server/src/manifest-loader.ts @@ -26,6 +26,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { resolve } from "node:path"; import { defaultPackagesDir } from "./manifest.js"; import { validateManifestPermissions } from "./permission-gate.js"; +import { isRetiredPlatformModule, PLATFORM_MONITORING_SERVICES, PLATFORM_SERVICE_OWNER } from "@sfmc-bds/sdk/contracts"; export interface ManifestServiceIO { type: "object" | "array" | "string" | "number" | "boolean" | "null"; @@ -47,8 +48,6 @@ export interface ModuleManifestV2 { requires: string[]; permissions: string[]; services: { provides: ServiceEntry[]; requires: ServiceEntry[] }; - enabledByDefault?: boolean | undefined; - canDisable?: boolean | undefined; notes?: string | undefined; } @@ -74,11 +73,10 @@ function assertModuleObject(modId: string, obj: Record): void { if (typeof obj.name !== "string" || obj.name.length === 0) { throw new Error(`[manifest] ${modId}: name 缺失`); } - if (obj.enabledByDefault !== undefined && typeof obj.enabledByDefault !== "boolean") { - throw new Error(`[manifest] ${modId}: enabledByDefault 必须是 boolean`); - } - if (obj.canDisable !== undefined && typeof obj.canDisable !== "boolean") { - throw new Error(`[manifest] ${modId}: canDisable 必须是 boolean`); + for (const field of ["enabledByDefault", "canDisable"]) { + if (Object.prototype.hasOwnProperty.call(obj, field)) { + throw new Error(`[manifest] ${modId}: ${field} 已停用,请删除;模块启停由服主通过 CLI/API 管理`); + } } if (typeof obj.configKey !== "string" || obj.configKey.length === 0) { throw new Error(`[manifest] ${modId}: configKey 缺失`); @@ -151,8 +149,6 @@ function parseMod(modId: string, raw: Record): ModuleManifestV2 requires, permissions, services: parseServices(modId, raw.services), - ...(typeof raw.enabledByDefault === "boolean" ? { enabledByDefault: raw.enabledByDefault } : {}), - ...(typeof raw.canDisable === "boolean" ? { canDisable: raw.canDisable } : {}), notes: typeof raw.notes === "string" ? raw.notes : undefined, }; } @@ -184,7 +180,7 @@ export function loadManifestV2(packagesDir: string = defaultPackagesDir()): Load }) .map((e) => e.name); const modules: Record = {}; - const providesMap = new Map(); + const providesMap = new Map(PLATFORM_MONITORING_SERVICES.map(name => [name, PLATFORM_SERVICE_OWNER])); for (const id of ids) { const p = resolve(packagesDir, id, "sapi", "manifest.json"); @@ -202,7 +198,9 @@ export function loadManifestV2(packagesDir: string = defaultPackagesDir()): Load throw new Error(`[manifest] ${id}: ${p} 非合法 JSON: ${(e as Error).message}`); } try { + if (typeof parsed.id === "string" && isRetiredPlatformModule(parsed.id)) continue; const m = parseMod(id, parsed); + if (m.id === PLATFORM_SERVICE_OWNER) throw new Error("_platform 是保留的平台身份,不能由模块声明"); modules[m.id] = m; for (const s of m.services.provides) { const prev = providesMap.get(s.name); @@ -231,7 +229,7 @@ export function loadManifestV2(packagesDir: string = defaultPackagesDir()): Load } // requires 模块依赖也要么 enabled 要么本身 installed (启动期不强求 enabled;运行时再查) for (const dep of m.requires) { - if (!modules[dep]) { + if (!modules[dep] && !isRetiredPlatformModule(dep)) { throw new Error(`[manifest] ${m.id}.requires.${dep} 没找到对应模块`); } } diff --git a/packages/db-server/src/routes/status.ts b/packages/db-server/src/routes/status.ts index 49428b17..051129c2 100644 --- a/packages/db-server/src/routes/status.ts +++ b/packages/db-server/src/routes/status.ts @@ -2,6 +2,12 @@ import { collectSystemStatus, type SystemStatusSnapshot } from "../domain/system-status.js"; import { PROJECT_ROOT } from "../project-root.js"; +import { MetricsHistory, parseMetrics, type MetricsSample } from "../domain/metrics.js"; +import { collectProcessResources } from "../domain/process-resources.js"; +import type { QueryFn } from "../lib/sqlite.js"; +import fs from "node:fs"; +import pathModule from "node:path"; +import { stateDir } from "@sfmc-bds/sdk/node/config"; import { body, json, type RouteFactory } from "./_shared.js"; const FRESH_MS = 60_000; @@ -15,15 +21,25 @@ interface LiveSnapshot { interface Deps { collectSystem?: (projectRoot: string) => Promise; projectRoot?: string; + query?: QueryFn; } -function createStatusRoutes({ collectSystem, projectRoot }: Deps): ReturnType { +function createStatusRoutes({ collectSystem, projectRoot, query }: Deps): ReturnType { const root = projectRoot ?? PROJECT_ROOT; const collect = collectSystem ?? collectSystemStatus; let live: LiveSnapshot | null = null; + let metrics: MetricsSample | null = null; + const history = query ? new MetricsHistory(query) : undefined; + let systemCache: { data: SystemStatusSnapshot; at: number } | undefined; + let collecting: Promise | undefined; + const getSystem = async () => { + if (systemCache && Date.now() - systemCache.at < 5000) return systemCache.data; + collecting ??= collect(root).then(data => { systemCache = { data, at: Date.now() }; return data; }).finally(() => { collecting = undefined; }); + return collecting; + }; return async function handle({ path, method, req, res }): Promise { - if (path === "/api/sfmc/status/live") { + if (path === "/api/sfmc/status/live" || path === "/api/sfmc/metrics/live") { if (method !== "POST") { json(res, { success: false, error: "not_found" }, 404); return true; @@ -41,6 +57,12 @@ function createStatusRoutes({ collectSystem, projectRoot }: Deps): ReturnType ({ name: name.trim() })), world: { day, difficulty }, @@ -50,7 +72,7 @@ function createStatusRoutes({ collectSystem, projectRoot }: Deps): ReturnType 0 && bds?.pid === ownedPid; + const metricFresh = owned && metrics !== null && bds?.state === "running" && now - metrics.recordedAt <= 60_000 && + (bdsAgeMs === null || now - metrics.recordedAt <= bdsAgeMs + 2000); + const processes = system ? { db: await collectProcessResources(system.db.pid, system.db.running), bds: owned ? await collectProcessResources(system.bds.pid, system.bds.running) : null } : null; + json(res, { fresh: metricFresh, current: metricFresh ? metrics : null, updatedAt: metrics?.recordedAt ?? null, + history: history?.read(now) ?? [], host: system?.host ?? null, processes, resourcesUpdatedAt: system ? Date.now() : null, + note: metricFresh ? undefined : bds?.state === "stopped" ? "服务器未运行" : "等待平台指标同步" }); + return true; + } + json(res, { online: current?.online ?? [], world: current?.world ?? null, diff --git a/packages/db-server/src/service-registry.ts b/packages/db-server/src/service-registry.ts index 58c6dd25..21001454 100644 --- a/packages/db-server/src/service-registry.ts +++ b/packages/db-server/src/service-registry.ts @@ -14,6 +14,7 @@ import type { DatabaseSync } from "node:sqlite"; import type { ModuleManifestV2 } from "./manifest-loader.js"; import type { QueryFn } from "./lib/sqlite.js"; +import { PLATFORM_MONITORING_SERVICES, PLATFORM_SERVICE_OWNER } from "@sfmc-bds/sdk/contracts"; export interface ServiceCallResult { ok: true; @@ -99,7 +100,8 @@ export class ServiceRegistry { if (!handler) { throw new DispatchError(`service "${name}" 未注册`, "no_such_service", 404); } - if (!enabled.has(handler.moduleId)) { + const platformService = handler.moduleId === PLATFORM_SERVICE_OWNER && PLATFORM_MONITORING_SERVICES.some(value => value === name); + if (!platformService && !enabled.has(handler.moduleId)) { throw new DispatchError(`service "${name}" 提供方 ${handler.moduleId} 未 enabled`, "forbidden", 403); } const caller = enabled.get(callerModuleId); diff --git a/packages/desktop/.gitignore b/packages/desktop/.gitignore new file mode 100644 index 00000000..641a363c --- /dev/null +++ b/packages/desktop/.gitignore @@ -0,0 +1,5 @@ +staging/ +payload/ +release/ +.pack-work/ +.pack-cache/ diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml new file mode 100644 index 00000000..627e89a7 --- /dev/null +++ b/packages/desktop/electron-builder.yml @@ -0,0 +1,38 @@ +appId: cn.dogelake.sfmc.desktop +productName: SFMC Desktop +electronVersion: 44.5.1 +electronDist: node_modules/electron/dist +npmRebuild: false +directories: + app: staging + output: release +files: + - dist/** + - node_modules/** + - package.json + - runtime-manifest.json + - release-manifest.json +extraResources: + - from: payload + to: payload +asar: true +win: + target: + - target: nsis + arch: [x64] + - target: zip + arch: [x64] + verifyUpdateCodeSignature: true +nsis: + include: scripts/installer.nsh + oneClick: false + perMachine: false + allowToChangeInstallationDirectory: true + deleteAppDataOnUninstall: false +publish: + provider: github + owner: DogeLakeDev + repo: ScriptsForMinecraftServer + releaseType: release + channel: desktop +artifactName: SFMC-Desktop-${version}-${arch}.${ext} diff --git a/packages/desktop/index.html b/packages/desktop/index.html new file mode 100644 index 00000000..2fa80b4d --- /dev/null +++ b/packages/desktop/index.html @@ -0,0 +1 @@ +SFMC Desktop
diff --git a/packages/desktop/package.json b/packages/desktop/package.json new file mode 100644 index 00000000..8833bbbd --- /dev/null +++ b/packages/desktop/package.json @@ -0,0 +1,45 @@ +{ + "name": "@sfmc-bds/desktop", + "version": "0.1.0", + "private": true, + "description": "SFMC 桌面管理客户端", + "license": "AGPL-3.0-only", + "type": "module", + "main": "dist/main.cjs", + "files": [ + "dist", + "runtime-manifest.json" + ], + "scripts": { + "dev": "node scripts/dev.mjs", + "build": "node scripts/build.mjs", + "typecheck": "tsc7 --noEmit -p tsconfig.json", + "package": "node scripts/package.mjs" + }, + "dependencies": { + "@base-ui/react": "1.8.0", + "@fontsource-variable/inter": "5.3.0", + "@fontsource-variable/jetbrains-mono": "5.3.0", + "@fontsource-variable/noto-sans-sc": "5.3.0", + "@monaco-editor/react": "4.7.0", + "@phosphor-icons/react": "2.1.10", + "@sfmc-bds/management": "workspace:*", + "echarts": "6.1.0", + "electron-updater": "6.8.9", + "jsonc-parser": "3.3.1", + "monaco-editor": "0.57.0", + "react": "19.3.0", + "react-dom": "19.3.0", + "ssh2": "1.17.0" + }, + "devDependencies": { + "@sfmc-bds/sfmc": "workspace:*", + "@sfmc-bds/tools": "workspace:*", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@types/ssh2": "1.15.6", + "electron": "44.5.1", + "electron-builder": "26.15.3", + "vite": "8.3.1" + } +} diff --git a/packages/desktop/runtime-manifest.json b/packages/desktop/runtime-manifest.json new file mode 100644 index 00000000..81ba25bf --- /dev/null +++ b/packages/desktop/runtime-manifest.json @@ -0,0 +1,8 @@ +{ + "node": "24.21.0", + "pnpm": "10.8.0", + "protocolVersion": 1, + "platformPackage": "@sfmc-bds/sfmc", + "hostArchitectures": ["x64"], + "hostSystems": ["win32", "linux"] +} diff --git a/packages/desktop/scripts/build.mjs b/packages/desktop/scripts/build.mjs new file mode 100644 index 00000000..7b10e699 --- /dev/null +++ b/packages/desktop/scripts/build.mjs @@ -0,0 +1,6 @@ +import { build as esbuild } from 'esbuild'; +import { build as vite } from 'vite'; +import path from 'node:path'; +const base = process.cwd(); +await esbuild({ entryPoints: ['src/main/main.ts', 'src/main/preload.ts'], outdir: 'dist', outbase: 'src/main', bundle: true, platform: 'node', format: 'cjs', target: 'node22', sourcemap: true, outExtension: { '.js': '.cjs' }, external: ['electron', 'ssh2', 'electron-updater'] }); +await vite({ root: base, base: './', build: { outDir: path.join(base, 'dist/renderer'), emptyOutDir: false, chunkSizeWarningLimit: 2500 }, server: { host: '127.0.0.1' } }); diff --git a/packages/desktop/scripts/dev.mjs b/packages/desktop/scripts/dev.mjs new file mode 100644 index 00000000..537828c5 --- /dev/null +++ b/packages/desktop/scripts/dev.mjs @@ -0,0 +1,9 @@ +import { spawn } from 'node:child_process'; +import { createServer } from 'vite'; +import { build } from 'esbuild'; +import electron from 'electron'; +await build({ entryPoints: ['src/main/main.ts', 'src/main/preload.ts'], outdir: 'dist', outbase: 'src/main', bundle: true, platform: 'node', format: 'cjs', outExtension: { '.js': '.cjs' }, external: ['electron', 'ssh2', 'electron-updater'] }); +const server = await createServer({ server: { host: '127.0.0.1', port: 5173, strictPort: true } }); +await server.listen(); +const child = spawn(electron, ['.'], { env: { ...process.env, SFMC_DESKTOP_DEV_URL: 'http://127.0.0.1:5173', SFMC_DESKTOP_DEV_NODE: process.execPath }, stdio: 'inherit', windowsHide: true }); +child.on('exit', async code => { await server.close(); process.exitCode = code ?? 0; }); diff --git a/packages/desktop/scripts/installer.nsh b/packages/desktop/scripts/installer.nsh new file mode 100644 index 00000000..dce035df --- /dev/null +++ b/packages/desktop/scripts/installer.nsh @@ -0,0 +1,8 @@ +!macro customInstall + FileOpen $0 "$INSTDIR\installed.json" w + FileWrite $0 '{"installed":true}' + FileClose $0 +!macroend +!macro customUnInstall + Delete "$INSTDIR\installed.json" +!macroend diff --git a/packages/desktop/scripts/package.mjs b/packages/desktop/scripts/package.mjs new file mode 100644 index 00000000..c47a157c --- /dev/null +++ b/packages/desktop/scripts/package.mjs @@ -0,0 +1,124 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; +import { spawn } from 'node:child_process'; + +const base = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +const root = path.resolve(base, '../..'); +const manifest = JSON.parse(await fs.readFile(path.join(base, 'runtime-manifest.json'), 'utf8')); +const desktop = JSON.parse(await fs.readFile(path.join(base, 'package.json'), 'utf8')); +const material = path.join(base, 'payload'); +const work = path.join(base, '.pack-work'); +const staging = path.join(base, 'staging'); +const cache = path.join(base, '.pack-cache'); +const pnpmEntry = process.env.SFMC_PNPM_ENTRY ?? process.env.npm_execpath; +if (!pnpmEntry || !pnpmEntry.includes('pnpm')) throw new Error('请通过 pnpm desktop:package 执行,或设置 SFMC_PNPM_ENTRY'); +const ledger = new Map(); + +async function command(bin, args, cwd = root) { + await new Promise((resolve, reject) => { + const child = spawn(bin, args, { cwd, env: { ...process.env, CI: 'true' }, windowsHide: true, stdio: 'inherit' }); + child.on('error', reject); child.on('close', code => code === 0 ? resolve() : reject(new Error(`${path.basename(bin)} 失败 (${code})`))); + }); +} +const pnpm = args => command(process.execPath, [pnpmEntry, ...args]); +async function reset(directory) { + if (path.dirname(directory) !== base) throw new Error('构建目录超出桌面包范围'); + await fs.rm(directory, { recursive: true, force: true }); await fs.mkdir(directory, { recursive: true }); +} +async function download(url, target, hash) { + await fs.mkdir(cache, { recursive: true }); + const cached = path.join(cache, createHash('sha256').update(url).digest('hex')); + try { const existing = await fs.readFile(cached); const actual = createHash('sha256').update(existing).digest('hex'); if (!hash || hash === actual) { await fs.writeFile(target, existing); return actual; } } catch { /* 首次下载 */ } + const response = await fetch(url, { signal: AbortSignal.timeout(300_000) }); + if (!response.ok) throw new Error(`下载失败: ${url} (${response.status})`); + const bytes = Buffer.from(await response.arrayBuffer()); + const actual = createHash('sha256').update(bytes).digest('hex'); + if (hash && actual !== hash) throw new Error(`校验失败: ${url}`); + await fs.writeFile(target, bytes); await fs.writeFile(cached, bytes); return actual; +} +async function resolveDependency(source, name) { + let cursor = source; + for (;;) { + const candidate = path.join(cursor, 'node_modules', name); + try { await fs.access(path.join(candidate, 'package.json')); return await fs.realpath(candidate); } catch { /* 上一层 */ } + const parent = path.dirname(cursor); if (parent === cursor) throw new Error(`无法收集生产依赖 ${name}`); cursor = parent; + } +} +/** 将 pnpm 部署树转换成普通目录;循环依赖由 Node 向祖先目录解析,发行材料中没有开发链接。 */ +async function copyPackage(source, target, ancestors = new Map()) { + source = await fs.realpath(source); + const pkg = JSON.parse(await fs.readFile(path.join(source, 'package.json'), 'utf8')); + await fs.mkdir(target, { recursive: true }); + const selected = pkg.files ? new Set(['package.json', 'LICENSE', 'LICENSE.md', 'NOTICE', ...pkg.files.map(name => name.split('/')[0])]) : null; + for (const name of await fs.readdir(source)) { + if (['node_modules', '.git', '.pack-work', 'staging', 'payload', 'release'].includes(name)) continue; + if (selected && !selected.has(name)) continue; + await fs.cp(path.join(source, name), path.join(target, name), { recursive: true, dereference: true }); + } + ledger.set(`${pkg.name}@${pkg.version}`, { name: pkg.name, version: pkg.version }); + const chain = new Map(ancestors); chain.set(pkg.name, source); + for (const [name] of Object.entries({ ...pkg.dependencies, ...pkg.optionalDependencies })) { + let dependency; + try { dependency = await resolveDependency(source, name); } catch (error) { if (pkg.optionalDependencies?.[name]) continue; throw error; } + const childPkg = JSON.parse(await fs.readFile(path.join(dependency, 'package.json'), 'utf8')); + if (pkg.dependencies?.[name]) pkg.dependencies[name] = childPkg.version; + if (pkg.optionalDependencies?.[name]) pkg.optionalDependencies[name] = childPkg.version; + if (childPkg.os && !childPkg.os.includes(process.platform) && !childPkg.os.includes(`!${process.platform}`)) { + if (pkg.optionalDependencies?.[name]) continue; + } + if (chain.get(name) === dependency) continue; + await copyPackage(dependency, path.join(target, 'node_modules', name), chain); + } + await fs.writeFile(path.join(target, 'package.json'), JSON.stringify(pkg, null, 2)); + if (pkg.name === 'esbuild') { + const linuxTar = path.join(work, `esbuild-linux-${pkg.version}.tgz`); + const linux = path.join(work, `esbuild-linux-${pkg.version}`); + const binaryTarget = path.join(target, 'node_modules', '@esbuild', 'linux-x64'); + await fs.mkdir(linux, { recursive: true }); + await download(`https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-${pkg.version}.tgz`, linuxTar); + await command('tar', ['-xf', linuxTar, '-C', linux]); + const hash = createHash('sha256').update(await fs.readFile(path.join(linux, 'package/bin/esbuild'))).digest('hex'); + if (pkg['esbuild.binaryHashes']?.['@esbuild/linux-x64/bin/esbuild'] && hash !== pkg['esbuild.binaryHashes']['@esbuild/linux-x64/bin/esbuild']) throw new Error('Linux esbuild 原生材料校验失败'); + await fs.cp(path.join(linux, 'package'), binaryTarget, { recursive: true }); + ledger.set(`@esbuild/linux-x64@${pkg.version}`, { name: '@esbuild/linux-x64', version: pkg.version }); + } +} + +await reset(work); await reset(staging); await reset(material); +await pnpm(['--filter', '@sfmc-bds/sfmc...', 'build']); +await pnpm(['--filter', '@sfmc-bds/desktop', 'build']); +// 收集当前 pnpm 锁定并安装的生产依赖闭包;不重新解析版本范围、不携带工作区链接。 +const platform = path.join(work, 'platform'); +await copyPackage(path.join(root, 'packages/meta'), path.join(platform, 'node_modules', '@sfmc-bds', 'sfmc')); +await command('tar', ['-cf', path.join(material, 'platform.tar'), '-C', work, 'platform']); +const platformPackage = JSON.parse(await fs.readFile(path.join(root, 'packages/meta/package.json'), 'utf8')); +await fs.writeFile(path.join(staging, 'runtime-manifest.json'), JSON.stringify({ ...manifest, platformVersion: platformPackage.version }, null, 2)); +await fs.cp(path.join(base, 'dist'), path.join(staging, 'dist'), { recursive: true }); +const runtimeDeps = { ssh2: desktop.dependencies.ssh2, 'electron-updater': desktop.dependencies['electron-updater'] }; +await fs.writeFile(path.join(staging, 'package.json'), JSON.stringify({ name: desktop.name, version: desktop.version, description: desktop.description, author: 'DogeLakeDev', license: desktop.license, main: desktop.main, packageManager: `pnpm@${manifest.pnpm}`, dependencies: runtimeDeps }, null, 2)); +for (const name of Object.keys(runtimeDeps)) await copyPackage(await resolveDependency(base, name), path.join(staging, 'node_modules', name)); +const pnpmTar = path.join(work, 'pnpm.tgz'); +await download(`https://registry.npmjs.org/pnpm/-/pnpm-${manifest.pnpm}.tgz`, pnpmTar); +await command('tar', ['-xf', pnpmTar, '-C', work]); +await fs.rename(path.join(work, 'package'), path.join(material, 'pnpm')); +await command('tar', ['-cf', path.join(material, 'pnpm.tar'), '-C', material, 'pnpm']); +const archiveName = `node-v${manifest.node}-win-x64.zip`; +const sums = await (await fetch(`https://nodejs.org/dist/v${manifest.node}/SHASUMS256.txt`)).text(); +const nodeHash = sums.split('\n').find(line => line.endsWith(` ${archiveName}`))?.split(/\s+/)[0]; +if (!nodeHash) throw new Error('缺少固定 Node 版本的校验值'); +const nodeZip = path.join(work, archiveName); +await download(`https://nodejs.org/dist/v${manifest.node}/${archiveName}`, nodeZip, nodeHash); +await command('tar', ['-xf', nodeZip, '-C', work]); +await fs.rename(path.join(work, `node-v${manifest.node}-win-x64`), path.join(material, 'node')); +const winswHash = await download('https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe', path.join(material, 'WinSW-x64.exe'), '05b82d46ad331cc16bdc00de5c6332c1ef818df8ceefcd49c726553209b3a0da'); +const materials = []; +for (const name of ['platform.tar', 'pnpm.tar', 'WinSW-x64.exe']) materials.push({ name, sha256: createHash('sha256').update(await fs.readFile(path.join(material, name))).digest('hex') }); +await fs.writeFile(path.join(material, 'release-manifest.json'), JSON.stringify({ desktopVersion: desktop.version, platformVersion: platformPackage.version, node: { version: manifest.node, sha256: nodeHash }, pnpm: manifest.pnpm, winsw: { version: '2.12.0', sha256: winswHash }, protocolVersion: manifest.protocolVersion, packages: [...ledger.values()].sort((a, b) => a.name.localeCompare(b.name)), materials }, null, 2)); +await fs.cp(path.join(material, 'release-manifest.json'), path.join(staging, 'release-manifest.json')); +if (!process.argv.includes('--prepare-only')) { + const signed = process.argv.includes('--signed'); + if (signed && (!process.env.CSC_LINK || !process.env.CSC_KEY_PASSWORD || !process.env.SFMC_SIGNING_PUBLISHER)) throw new Error('签名发行需要 CSC_LINK、CSC_KEY_PASSWORD 和 SFMC_SIGNING_PUBLISHER;不会生成伪签名发行包'); + await command(process.execPath, [path.join(base, 'node_modules/electron-builder/out/cli/cli.js'), '--config', path.join(base, 'electron-builder.yml'), '--win', '--x64', '--publish', 'never', ...(signed ? ['--config.forceCodeSigning=true', `--config.win.publisherName=${process.env.SFMC_SIGNING_PUBLISHER}`] : ['--config.win.signAndEditExecutable=false'])], base); +} diff --git a/packages/desktop/src/main/hosts.ts b/packages/desktop/src/main/hosts.ts new file mode 100644 index 00000000..ab432d0e --- /dev/null +++ b/packages/desktop/src/main/hosts.ts @@ -0,0 +1,17 @@ +import fs from "node:fs"; +import path from "node:path"; +import { app } from "electron"; +const filename = () => path.join(app.getPath("userData"), "known-hosts.json"); +export function trustedHosts(): Record { + if (!fs.existsSync(filename())) return {}; + const values = JSON.parse(fs.readFileSync(filename(), "utf8")) as Record; + if (!values || typeof values !== "object" || Array.isArray(values) || Object.values(values).some(value => typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value))) throw new Error("主机指纹记录损坏,请先核实并恢复"); + return values as Record; +} +export function trustHost(key: string, fingerprint: string, expected?: string) { + const rows = trustedHosts(); + if (rows[key] && rows[key] !== fingerprint && rows[key] !== expected) throw new Error("主机信任记录已变化,请重新连接核对"); + rows[key] = fingerprint; + fs.mkdirSync(path.dirname(filename()), { recursive: true }); + const temp = filename() + ".tmp"; fs.writeFileSync(temp, JSON.stringify(rows), { mode: 0o600 }); fs.renameSync(temp, filename()); +} diff --git a/packages/desktop/src/main/main.ts b/packages/desktop/src/main/main.ts new file mode 100644 index 00000000..c1ed64dd --- /dev/null +++ b/packages/desktop/src/main/main.ts @@ -0,0 +1,133 @@ +import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Menu } from "electron"; +import path from "node:path"; +import fs from "node:fs"; +import { trustHost } from "./hosts.js"; +import { autoUpdater } from "electron-updater"; +import { Session } from "./sessions.js"; +import { profiles, profile, saveProfile, removeProfile, credentials, rememberSession } from "./profiles.js"; +import type { AppearanceMode, Credentials, InstanceProfile } from "../shared/api.js"; +import type { ManagementMethod } from "@sfmc-bds/management"; + +const sessions = new Map(); +const changedHosts = new Map>(); +let window: BrowserWindow; +app.setName("SFMC Desktop"); +if (!app.isPackaged && process.env.SFMC_DESKTOP_USER_DATA) app.setPath("userData", process.env.SFMC_DESKTOP_USER_DATA); +if (!app.requestSingleInstanceLock()) process.exit(0); +app.on("second-instance", () => { window?.show(); window?.focus(); }); + +/** SnowUI 窗口底色:浅色 Background/1、深色 Background/1 */ +const THEME_CHROME = { + light: { background: "#ffffff", overlay: "#ffffff", symbol: "#1c1c1c" }, + dark: { background: "#17191c", overlay: "#1c1f23", symbol: "#e8eaed" }, +} as const; + +/** 当前解析后的明暗(system 跟随 nativeTheme.shouldUseDarkColors) */ +function resolvedDark(mode: AppearanceMode): boolean { + return mode === "dark" || (mode === "system" && nativeTheme.shouldUseDarkColors); +} + +/** + * 把外观同步到原生窗口:底色、Windows 标题栏覆盖层按钮配色。 + * 使用场景:启动时按系统主题初始化,以及渲染进程调用 appearance() 之后。 + */ +function applyChrome(mode: AppearanceMode) { + const chrome = THEME_CHROME[resolvedDark(mode) ? "dark" : "light"]; + if (!window || window.isDestroyed()) return; + window.setBackgroundColor(chrome.background); + if (process.platform === "win32") { + window.setTitleBarOverlay({ color: chrome.overlay, symbolColor: chrome.symbol, height: 36 }); + } +} + +void app.whenReady().then(async () => { + if (app.isPackaged) Menu.setApplicationMenu(null); + const initialDark = nativeTheme.shouldUseDarkColors; + const chrome = THEME_CHROME[initialDark ? "dark" : "light"]; + window = new BrowserWindow({ + width: 1380, + height: 900, + minWidth: 1050, + minHeight: 700, + backgroundColor: chrome.background, + show: false, + titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "hidden", + ...(process.platform === "win32" ? { titleBarOverlay: { color: chrome.overlay, symbolColor: chrome.symbol, height: 36 } } : {}), + webPreferences: { preload: path.join(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true }, + }); + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("will-navigate", event => event.preventDefault()); + window.once("ready-to-show", () => window.show()); + const handle = (channel: string, action: (...args: unknown[]) => unknown) => ipcMain.handle(`sfmc:${channel}`, (event, ...args: unknown[]) => { if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error("非法调用来源"); return action(...args); }); + handle("profiles", () => profiles()); + handle("saveProfile", (input, secret) => saveProfile(input as InstanceProfile, secret as Credentials)); + handle("removeProfile", id => { const key = String(id); sessions.get(key)?.disconnect(); sessions.delete(key); return removeProfile(key); }); + handle("connect", async (id, supplied) => { + const key = String(id); sessions.get(key)?.disconnect(); sessions.delete(key); + rememberSession(key, supplied as Credentials | undefined); + const session = new Session(profile(key), { ...credentials(key), ...(supplied as Credentials ?? {}) }, message => { if (!window.isDestroyed() && sessions.get(key) === session) window.webContents.send("sfmc:connection", key, message); }); + sessions.set(key, session); + try { + const handshake = await session.connect(); + session.client!.on("event", event => { if (!window.isDestroyed()) window.webContents.send("sfmc:event", key, event); }); + await session.client!.call("events.subscribe"); return handshake; + } catch (error) { if (session.changedFingerprint) changedHosts.set(key, session.changedFingerprint); session.disconnect(); sessions.delete(key); throw error; } + }); + handle("confirmHost", async id => { + const key = String(id); const current = profile(key); const changed = changedHosts.get(key); + if (!changed || changed.key !== `${current.host}:${current.port ?? 22}`) throw new Error("请先连接此实例以读取待核实的主机指纹"); + const result = await dialog.showMessageBox(window, { type: "warning", title: "重新确认主机身份", message: `主机 ${changed.key} 的身份已改变`, detail: `原 SHA256: ${changed.previous}\n新 SHA256: ${changed.current}\n请通过可信渠道核对新指纹,核实后再重新接入。`, buttons: ["取消", "已核实新指纹"], defaultId: 0, cancelId: 0 }); + if (result.response !== 1) return false; + trustHost(changed.key, changed.current, changed.previous); changedHosts.delete(key); return true; + }); + handle("disconnect", id => { sessions.get(String(id))?.disconnect(); sessions.delete(String(id)); }); + handle("request", (id, method, params) => { + const session = sessions.get(String(id)); if (!session?.client) throw new Error("实例尚未连接"); + return session.client.call(method as ManagementMethod, params); + }); + handle("choose", async kind => { const result = await dialog.showOpenDialog(window, kind === "directory" ? { properties: ["openDirectory", "createDirectory"] } : { properties: ["openFile"], ...(kind === "pack" ? { filters: [{ name: "Minecraft 世界包", extensions: ["mcpack", "mcaddon", "zip"] }] } : {}) }); return result.canceled ? null : result.filePaths[0] ?? null; }); + handle("uploadPack", async id => { const session = sessions.get(String(id)); if (!session) throw new Error("实例尚未连接"); const result = await dialog.showOpenDialog(window, { properties: ["openFile"], filters: [{ name: "Minecraft 世界包", extensions: ["mcpack", "mcaddon", "zip"] }] }); if (result.canceled || !result.filePaths[0]) return null; return session.uploadPack(result.filePaths[0]); }); + autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; autoUpdater.channel = "desktop"; + autoUpdater.on("error", error => { if (!window.isDestroyed()) window.webContents.send("sfmc:connection", "", `客户端更新失败: ${error.message}`); }); + handle("openLink", kind => { + const urls = { eula: "https://www.minecraft.net/eula", "desktop-release": "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/releases" }; + if (!(String(kind) in urls)) throw new Error("未知外部链接"); + return shell.openExternal(urls[kind as keyof typeof urls]); + }); + handle("update", async action => { + if (app.isPackaged && !fs.existsSync(path.join(path.dirname(process.execPath), "installed.json"))) return { portable: true, downloadUrl: "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/releases" }; + if (!app.isPackaged) return { development: true }; + if (action === "check") { + let release: { tag_name: string; body: string } | undefined; + for (let page = 1; page <= 5 && !release; page++) { + const response = await fetch(`https://api.github.com/repos/DogeLakeDev/ScriptsForMinecraftServer/releases?per_page=100&page=${page}`, { signal: AbortSignal.timeout(20_000) }); + if (!response.ok) throw new Error(`桌面发行检查失败 (${response.status})`); + const rows = await response.json() as { tag_name: string; body: string; draft: boolean; prerelease: boolean }[]; + release = rows.find(row => !row.draft && !row.prerelease && /^desktop-v\d+\.\d+\.\d+$/.test(row.tag_name)); + if (rows.length < 100) break; + } + if (!release) throw new Error("尚无稳定桌面发行版"); + autoUpdater.setFeedURL({ provider: "generic", url: `https://github.com/DogeLakeDev/ScriptsForMinecraftServer/releases/download/${release.tag_name}`, channel: "desktop" }); + const result = await autoUpdater.checkForUpdates(); return { ...result?.updateInfo, releaseNotes: release.body, available: Boolean(result?.isUpdateAvailable) }; + } + if (action === "download") return autoUpdater.downloadUpdate(); + if (action === "install") { autoUpdater.quitAndInstall(); return { installing: true }; } + throw new Error("未知更新操作"); + }); + /** 外观:校验模式后设置 nativeTheme 并同步标题栏覆盖层(渲染进程切换主题时调用) */ + handle("appearance", (mode) => { + const next = mode === "light" || mode === "dark" || mode === "system" ? mode : "system"; + nativeTheme.themeSource = next; + applyChrome(next); + }); + /** 客户端版本与运行形态(关于对话框、侧栏页脚) */ + handle("appInfo", () => ({ version: app.getVersion(), platform: process.platform, packaged: app.isPackaged })); + nativeTheme.on("updated", () => { + if (nativeTheme.themeSource === "system") applyChrome("system"); + }); + const dev = !app.isPackaged ? process.env.SFMC_DESKTOP_DEV_URL : undefined; + if (dev && /^http:\/\/127\.0\.0\.1:\d+$/.test(dev)) await window.loadURL(dev); + else await window.loadFile(path.join(__dirname, "renderer", "index.html")); +}); +app.on("window-all-closed", () => app.quit()); +app.on("before-quit", () => { for (const session of sessions.values()) session.disconnect(); }); diff --git a/packages/desktop/src/main/preload.ts b/packages/desktop/src/main/preload.ts new file mode 100644 index 00000000..37308d9c --- /dev/null +++ b/packages/desktop/src/main/preload.ts @@ -0,0 +1,20 @@ +import { contextBridge, ipcRenderer } from "electron"; +import type { DesktopApi } from "../shared/api.js"; +const api: DesktopApi = { + profiles: () => ipcRenderer.invoke("sfmc:profiles"), + saveProfile: (profile, credentials) => ipcRenderer.invoke("sfmc:saveProfile", profile, credentials), + removeProfile: id => ipcRenderer.invoke("sfmc:removeProfile", id), + connect: (id, credentials) => ipcRenderer.invoke("sfmc:connect", id, credentials), + disconnect: id => ipcRenderer.invoke("sfmc:disconnect", id), + confirmHost: id => ipcRenderer.invoke("sfmc:confirmHost", id), + request: (id, method, params) => ipcRenderer.invoke("sfmc:request", id, method, params), + choose: kind => ipcRenderer.invoke("sfmc:choose", kind), + uploadPack: id => ipcRenderer.invoke("sfmc:uploadPack", id), + update: action => ipcRenderer.invoke("sfmc:update", action), + openLink: kind => ipcRenderer.invoke("sfmc:openLink", kind), + appearance: mode => ipcRenderer.invoke("sfmc:appearance", mode), + appInfo: () => ipcRenderer.invoke("sfmc:appInfo"), + onEvent: callback => { const listener = (_event: unknown, id: string, event: Parameters[0]>[1]) => callback(id, event); ipcRenderer.on("sfmc:event", listener); return () => ipcRenderer.removeListener("sfmc:event", listener); }, + onConnection: callback => { const listener = (_event: unknown, id: string, message: string) => callback(id, message); ipcRenderer.on("sfmc:connection", listener); return () => ipcRenderer.removeListener("sfmc:connection", listener); }, +}; +contextBridge.exposeInMainWorld("sfmc", api); diff --git a/packages/desktop/src/main/profiles.ts b/packages/desktop/src/main/profiles.ts new file mode 100644 index 00000000..ac44ed14 --- /dev/null +++ b/packages/desktop/src/main/profiles.ts @@ -0,0 +1,44 @@ +import { app, safeStorage } from "electron"; +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import type { InstanceProfile, Credentials } from "../shared/api.js"; + +type Stored = InstanceProfile & { encrypted?: string }; +const sessionSecrets = new Map(); +export function rememberSession(id: string, secret?: Credentials) { + if (!secret) return; + const value = { ...(typeof secret.password === "string" && secret.password ? { password: secret.password } : {}), ...(typeof secret.passphrase === "string" && secret.passphrase ? { passphrase: secret.passphrase } : {}) }; + if (Object.keys(value).length) sessionSecrets.set(id, value); +} +function filename() { return path.join(app.getPath("userData"), "instances.json"); } +function stored(): Stored[] { try { return JSON.parse(fs.readFileSync(filename(), "utf8")) as Stored[]; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; } } +function write(rows: Stored[]) { + fs.mkdirSync(path.dirname(filename()), { recursive: true }); + const temp = filename() + ".tmp"; fs.writeFileSync(temp, JSON.stringify(rows, null, 2), { mode: 0o600 }); fs.renameSync(temp, filename()); +} +export function profiles() { return stored().map(({ encrypted, ...profile }) => ({ ...profile, hasCredential: Boolean(encrypted || sessionSecrets.has(profile.id)) })); } +export function profile(id: string): InstanceProfile { const found = profiles().find(row => row.id === id); if (!found) throw new Error("实例不存在"); return found; } +export function credentials(id: string): Credentials { + const session = sessionSecrets.get(id); if (session) return { ...session }; + const row = stored().find(row => row.id === id); + if (!row?.encrypted) return {}; + if (!safeStorage.isEncryptionAvailable()) throw new Error("当前系统无法解密保存的凭据"); + return JSON.parse(safeStorage.decryptString(Buffer.from(row.encrypted, "base64"))) as Credentials; +} +export function saveProfile(input: InstanceProfile, secret: Credentials) { + if (!input || !["local", "ssh"].includes(input.kind) || typeof input.name !== "string" || !input.name.trim() || typeof input.root !== "string" || !input.root.trim() || /[\r\n\0]/.test(input.root)) throw new Error("实例名称和目录无效"); + if (input.kind === "ssh" && (!input.host || !input.username || !["linux", "windows"].includes(input.os ?? ""))) throw new Error("SSH 连接信息不完整"); + if (input.kind === "ssh" && (typeof input.host !== "string" || typeof input.username !== "string" || /[\r\n\0]/.test(input.host + input.username) || !Number.isInteger(input.port ?? 22) || (input.port ?? 22) < 1 || (input.port ?? 22) > 65535)) throw new Error("SSH 主机、账号或端口无效"); + secret = { ...(typeof secret?.password === "string" ? { password: secret.password } : {}), ...(typeof secret?.passphrase === "string" ? { passphrase: secret.passphrase } : {}) }; + const rows = stored(); const id = input.id || randomUUID(); + const previous = rows.find(row => row.id === id); + rememberSession(id, secret); + const row: Stored = { id, name: input.name.trim(), root: input.root.trim(), kind: input.kind, port: input.port || 22, host: input.host ?? "", username: input.username ?? "", os: input.os ?? "linux", privateKeyPath: input.privateKeyPath ?? "", remember: input.remember === true }; + if (row.remember) { + if (!safeStorage.isEncryptionAvailable()) throw new Error("系统凭据加密不可用,不会保存明文密码"); + row.encrypted = Object.keys(secret).some(key => secret[key as keyof Credentials]) ? safeStorage.encryptString(JSON.stringify(secret)).toString("base64") : previous?.encrypted; + } + write([...rows.filter(existing => existing.id !== id), row]); return profiles(); +} +export function removeProfile(id: string) { sessionSecrets.delete(id); write(stored().filter(row => row.id !== id)); return profiles(); } diff --git a/packages/desktop/src/main/runtime.ts b/packages/desktop/src/main/runtime.ts new file mode 100644 index 00000000..51d2c8e3 --- /dev/null +++ b/packages/desktop/src/main/runtime.ts @@ -0,0 +1,52 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { spawn } from "node:child_process"; +import { app } from "electron"; + +export function payload() { return app.isPackaged ? path.join(process.resourcesPath, "payload") : path.join(app.getAppPath(), "payload"); } +export function releaseManifest(): { platformVersion: string; node: { version: string }; pnpm: string; materials: { name: string; sha256: string }[] } { + return JSON.parse(fs.readFileSync(path.join(payload(), "release-manifest.json"), "utf8")); +} +export function verifyMaterial(name: string) { + const material = releaseManifest().materials.find(row => row.name === name); + const file = path.join(payload(), name); + if (!material || createHash("sha256").update(fs.readFileSync(file)).digest("hex") !== material.sha256) throw new Error(`发行材料校验失败: ${name}`); + return file; +} +export const launcherSource = "import fs from 'node:fs'; import path from 'node:path'; import {pathToFileURL,fileURLToPath} from 'node:url'; const base=path.dirname(fileURLToPath(import.meta.url)); const active=JSON.parse(fs.readFileSync(path.join(base,'active.json'),'utf8')); process.env.SFMC_DAEMON_ENTRY=fileURLToPath(import.meta.url); process.env.SFMC_NODE_BINARY=process.execPath; if(active.pnpm) process.env.SFMC_PNPM_ENTRY=active.pnpm; await import(pathToFileURL(active.entry).href);\n"; +export async function checkedDownload(url: string, destination: string, expected?: string) { + const response = await fetch(url, { signal: AbortSignal.timeout(120_000) }); + if (!response.ok) throw new Error(`下载失败 (${response.status})`); + const data = Buffer.from(await response.arrayBuffer()); + if (expected && createHash("sha256").update(data).digest("hex") !== expected) throw new Error("下载内容校验失败"); + fs.mkdirSync(path.dirname(destination), { recursive: true }); const temp = destination + ".tmp"; fs.writeFileSync(temp, data); fs.renameSync(temp, destination); +} +export function exec(command: string, args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); let output = ""; child.stderr.on("data", chunk => { output = (output + String(chunk)).slice(-4000); }); child.on("error", reject); child.on("close", code => code === 0 ? resolve() : reject(new Error(`准备运行时失败 (${code}): ${output}`))); }); +} +export async function ensureLocalRuntime(root: string) { + if (!path.isAbsolute(root)) throw new Error("本机部署目录必须为绝对路径"); + fs.mkdirSync(root, { recursive: true }); + if (!app.isPackaged && process.env.SFMC_DESKTOP_DEV_NODE) { + const entry = path.resolve(app.getAppPath(), "../meta/bin/sfmc.mjs"); + return { node: process.env.SFMC_DESKTOP_DEV_NODE, entry, pnpm: process.env.SFMC_PNPM_ENTRY }; + } + const manifest = releaseManifest(); + const directory = path.join(root, ".sfmc", "runtime"); + const node = path.join(directory, `node-${manifest.node.version}`, "node.exe"); + const bootstrap = path.join(directory, `bootstrap-${manifest.platformVersion}`); + const bootstrapEntry = path.join(bootstrap, "platform", "node_modules", "@sfmc-bds", "sfmc", "bin", "sfmc.mjs"); + if (!fs.existsSync(node)) await fs.promises.cp(path.join(payload(), "node"), path.dirname(node), { recursive: true, dereference: true }); + if (!fs.existsSync(bootstrapEntry)) { fs.mkdirSync(bootstrap, { recursive: true }); await exec("tar.exe", ["-xf", verifyMaterial("platform.tar"), "-C", bootstrap], root); } + const pnpm = path.join(directory, `pnpm-${manifest.pnpm}`, "bin", "pnpm.cjs"); + if (!fs.existsSync(pnpm)) await fs.promises.cp(path.join(payload(), "pnpm"), path.dirname(path.dirname(pnpm)), { recursive: true, dereference: true }); + const launcher = path.join(directory, "launcher.mjs"); + const activeFile = path.join(directory, "active.json"); + if (!fs.existsSync(activeFile)) fs.writeFileSync(activeFile, JSON.stringify({ entry: bootstrapEntry, version: manifest.platformVersion, pnpm })); + if (!fs.existsSync(launcher)) fs.writeFileSync(launcher, launcherSource); + const wrapper = path.join(directory, "WinSW-x64.exe"); + if (!fs.existsSync(wrapper)) await fs.promises.copyFile(verifyMaterial("WinSW-x64.exe"), wrapper); + const entry = launcher; + return { node, entry, pnpm }; +} diff --git a/packages/desktop/src/main/sessions.ts b/packages/desktop/src/main/sessions.ts new file mode 100644 index 00000000..75c67cba --- /dev/null +++ b/packages/desktop/src/main/sessions.ts @@ -0,0 +1,144 @@ +import { Client, type SFTPWrapper, type ClientChannel } from "ssh2"; +import { dialog, app } from "electron"; +import fs from "node:fs"; +import path from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; +import { Duplex, Readable, Writable } from "node:stream"; +import { ManagementClient } from "@sfmc-bds/management/node"; +import type { Credentials, InstanceProfile } from "../shared/api.js"; +import { ensureLocalRuntime, payload, checkedDownload, releaseManifest, verifyMaterial, launcherSource } from "./runtime.js"; +import { trustedHosts, trustHost } from "./hosts.js"; + +const shellQuote = (text: string) => "'" + text.replace(/'/g, "'\\''") + "'"; +const powershell = (script: string) => "powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand " + Buffer.from(script, "utf16le").toString("base64"); +const psValue = (text: string) => `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(text).toString("base64")}'))`; +export class Session { + changedFingerprint?: { key: string; previous: string; current: string }; + client?: ManagementClient; + ssh?: Client; + child?: ChildProcess; + private sftp?: SFTPWrapper; + constructor(readonly profile: InstanceProfile, private credentials: Credentials, private notify: (message: string) => void) { + this.credentials = { ...(typeof credentials.password === "string" ? { password: credentials.password } : {}), ...(typeof credentials.passphrase === "string" ? { passphrase: credentials.passphrase } : {}) }; + } + async connect() { + if (this.profile.kind === "local") { + const runtime = await ensureLocalRuntime(this.profile.root); + const child = spawn(runtime.node, [runtime.entry, "manage", "--stdio"], { cwd: this.profile.root, env: { ...process.env, SFMC_ROOT: this.profile.root, SFMC_NODE_BINARY: runtime.node, SFMC_DAEMON_ENTRY: runtime.entry, ...(runtime.pnpm ? { SFMC_PNPM_ENTRY: runtime.pnpm } : {}) }, windowsHide: true, stdio: "pipe" }); + this.child = child; + child.on("error", error => this.notify(error.message)); + child.stderr?.on("data", () => {}); + this.client = new ManagementClient(Duplex.fromWeb({ readable: Readable.toWeb(child.stdout!), writable: Writable.toWeb(child.stdin!) })); + } else { + await this.connectSsh(); + const runtime = await this.prepareRemote(); + const command = this.profile.os === "windows" + ? powershell(`$env:SFMC_PNPM_ENTRY=${psValue(runtime.pnpm)}; $env:SFMC_NODE_BINARY=${psValue(runtime.node)}; $env:SFMC_DAEMON_ENTRY=${psValue(runtime.entry)}; & (${psValue(runtime.node)}) (${psValue(runtime.entry)}) manage --stdio`) + : `SFMC_PNPM_ENTRY=${shellQuote(runtime.pnpm)} SFMC_NODE_BINARY=${shellQuote(runtime.node)} SFMC_DAEMON_ENTRY=${shellQuote(runtime.entry)} ${shellQuote(runtime.node)} ${shellQuote(runtime.entry)} manage --stdio`; + const stream = await this.execStream(command); + stream.stderr.on("data", () => {}); + this.client = new ManagementClient(stream); + } + this.client.on("disconnected", message => this.notify(`管理连接已断开: ${String(message)}`)); + return this.client.handshake(this.profile.root); + } + private async connectSsh() { + const ssh = new Client(); this.ssh = ssh; + const profile = this.profile; + const trusted = trustedHosts(); + await new Promise((resolve, reject) => { + ssh.once("ready", resolve); ssh.once("error", reject); ssh.on("close", () => this.notify("SSH 连接断开,服务器和后台任务继续运行")); + ssh.connect({ host: profile.host!, port: profile.port ?? 22, username: profile.username!, readyTimeout: 20_000, keepaliveInterval: 10_000, keepaliveCountMax: 3, + ...(profile.privateKeyPath ? { privateKey: fs.readFileSync(profile.privateKeyPath) } : {}), ...this.credentials, + hostHash: "sha256", hostVerifier: (fingerprint: string | Buffer, callback: (valid: boolean) => void): void => { + const hash = fingerprint.toString(); + const key = `${profile.host}:${profile.port ?? 22}`; + if (trusted[key]) { if (trusted[key] !== hash) { this.changedFingerprint = { key, previous: trusted[key]!, current: hash }; this.notify("SSH 主机指纹发生变化,连接已阻断;请核实服务器身份"); } callback(trusted[key] === hash); return; } + void dialog.showMessageBox({ type: "question", title: "确认 SSH 主机身份", message: `首次连接 ${key}`, detail: `SHA256(十六进制): ${hash}\n请与服务器管理员提供的指纹核对。`, buttons: ["取消", "信任并连接"], defaultId: 0, cancelId: 0 }).then(answer => { + if (answer.response === 1) trustHost(key, hash); + callback(answer.response === 1); + }).catch(() => callback(false)); + } }); + }); + this.sftp = await new Promise((resolve, reject) => ssh.sftp((error, sftp) => error ? reject(error) : resolve(sftp))); + } + private execStream(command: string): Promise { return new Promise((resolve, reject) => this.ssh!.exec(command, { pty: false }, (error, stream) => error ? reject(error) : resolve(stream))); } + private async execRemote(command: string) { + const stream = await this.execStream(command); + return new Promise((resolve, reject) => { let errorText = ""; stream.stderr.on("data", chunk => { errorText = (errorText + String(chunk)).slice(-4000); }); stream.on("data", () => {}); stream.on("close", (code: number) => code === 0 ? resolve() : reject(new Error(`远程准备失败 (${code}): ${errorText}`))); }); + } + private async upload(local: string, remote: string) { await new Promise((resolve, reject) => this.sftp!.fastPut(local, remote.replace(/\\/g, "/"), error => error ? reject(error) : resolve())); } + private async prepareRemote() { + const root = this.profile.root; + const windows = this.profile.os === "windows"; + if (windows ? !/^[A-Za-z]:[\\/]/.test(root) : !root.startsWith("/")) throw new Error("请填写远程系统的绝对部署目录"); + const join = windows ? path.win32.join : path.posix.join; + const directory = join(root, ".sfmc", "runtime"); + const manifest = releaseManifest(); + const nodeDirectory = join(directory, `node-${manifest.node.version}`); + const archive = join(directory, "platform.tar"); + const bootstrap = join(directory, `bootstrap-${manifest.platformVersion}`); + const node = join(nodeDirectory, windows ? "node.exe" : "bin/node"); + const bootstrapEntry = join(bootstrap, "platform", "node_modules", "@sfmc-bds", "sfmc", "bin", "sfmc.mjs"); + const entry = join(directory, "launcher.mjs"); + const pnpm = join(directory, `pnpm-${manifest.pnpm}`, "bin", "pnpm.cjs"); + const materials = verifyMaterial("platform.tar"); + if (!fs.existsSync(materials)) throw new Error("缺少远程部署材料,请先运行桌面打包准备流程"); + this.notify("正在准备远程独立运行时,首次接入可能需要下载"); + await this.execRemote(windows ? powershell(`$ErrorActionPreference='Stop'; New-Item -ItemType Directory -Force (${psValue(directory)}) | Out-Null`) : `mkdir -p -- ${shellQuote(directory)}`); + const nodeArchive = windows ? `node-v${manifest.node.version}-win-x64.zip` : `node-v${manifest.node.version}-linux-x64.tar.xz`; + const response = await fetch(`https://nodejs.org/dist/v${manifest.node.version}/SHASUMS256.txt`, { signal: AbortSignal.timeout(20_000) }); + if (!response.ok) throw new Error("无法获取 Node 校验清单"); + const checksum = (await response.text()).split("\n").find(line => line.endsWith(` ${nodeArchive}`))?.split(/\s+/)[0]; + if (!checksum || !/^[a-f0-9]{64}$/.test(checksum)) throw new Error("缺少固定版本 Node 校验值"); + const nodeUrl = `https://nodejs.org/dist/v${manifest.node.version}/${nodeArchive}`; + const nodeTarget = join(directory, nodeArchive); + // 远端自行下载并校验,下载不可用时交给客户端中转。 + const extractNode = windows + ? `if ((Get-FileHash -LiteralPath (${psValue(nodeTarget)}) -Algorithm SHA256).Hash.ToLower() -ne '${checksum}') { throw 'Node 校验失败' }; Expand-Archive -LiteralPath (${psValue(nodeTarget)}) -DestinationPath (${psValue(directory)}) -Force; if (Test-Path -LiteralPath (${psValue(nodeDirectory)})) { throw '不完整的 Node 目录已存在,请先检查' }; Move-Item -LiteralPath (${psValue(join(directory, `node-v${manifest.node.version}-win-x64`))}) -Destination (${psValue(nodeDirectory)})` + : `printf '%s %s\n' ${shellQuote(checksum)} ${shellQuote(nodeTarget)} | sha256sum -c -; mkdir -p ${shellQuote(nodeDirectory)}; tar -xf ${shellQuote(nodeTarget)} -C ${shellQuote(nodeDirectory)} --strip-components=1`; + if (!await this.remoteExists(node)) { + try { await this.execRemote(windows ? powershell(`$ErrorActionPreference='Stop'; Invoke-WebRequest '${nodeUrl}' -OutFile (${psValue(nodeTarget)})`) : `curl -fL ${shellQuote(nodeUrl)} -o ${shellQuote(nodeTarget)}`); } + catch { + this.notify("远端下载不可用,正在通过客户端中转独立 Node"); + const cached = path.join(app.getPath("temp"), `sfmc-${checksum}-${nodeArchive}`); + await checkedDownload(nodeUrl, cached, checksum); await this.upload(cached, nodeTarget); + } + await this.execRemote(windows ? powershell(`$ErrorActionPreference='Stop'; ${extractNode}`) : `set -eu; ${extractNode}`); + } + if (!await this.remoteExists(bootstrapEntry)) { + await this.upload(materials, archive); + await this.execRemote(windows + ? powershell(`$ErrorActionPreference='Stop'; New-Item -ItemType Directory -Force (${psValue(bootstrap)}) | Out-Null; tar -xf (${psValue(archive)}) -C (${psValue(bootstrap)}); if ($LASTEXITCODE -ne 0) { throw '平台解包失败' }`) + : `set -eu; mkdir -p ${shellQuote(bootstrap)}; tar -xf ${shellQuote(archive)} -C ${shellQuote(bootstrap)}; find ${shellQuote(bootstrap)} -type f -path '*/@esbuild/linux-x64/bin/esbuild' -exec chmod +x '{}' +`); + } + const pnpmArchive = path.join(payload(), "pnpm.tar"); + if (!await this.remoteExists(pnpm)) { + verifyMaterial("pnpm.tar"); await this.upload(pnpmArchive, join(directory, "pnpm.tar")); + await this.execRemote(windows ? powershell(`$ErrorActionPreference='Stop'; tar -xf (${psValue(join(directory, "pnpm.tar"))}) -C (${psValue(directory)}); if ($LASTEXITCODE -ne 0) { throw 'pnpm 解包失败' }; Move-Item -LiteralPath (${psValue(join(directory, "pnpm"))}) -Destination (${psValue(join(directory, `pnpm-${manifest.pnpm}`))})`) : `set -eu; tar -xf ${shellQuote(join(directory, "pnpm.tar"))} -C ${shellQuote(directory)}; mv -- ${shellQuote(join(directory, "pnpm"))} ${shellQuote(join(directory, `pnpm-${manifest.pnpm}`))}`); + } + for (const [name, content] of [["launcher.mjs", launcherSource], ["active.json", JSON.stringify({ entry: bootstrapEntry, version: manifest.platformVersion, pnpm })]]) { + if (!await this.remoteExists(join(directory, name!))) { + const temp = path.join(app.getPath("temp"), `sfmc-${this.profile.id}-${name}`); + fs.writeFileSync(temp, content!); await this.upload(temp, join(directory, name!)); fs.unlinkSync(temp); + } + } + if (windows && !await this.remoteExists(join(directory, "WinSW-x64.exe"))) await this.upload(verifyMaterial("WinSW-x64.exe"), join(directory, "WinSW-x64.exe")); + return { node, entry, pnpm }; + } + private async remoteExists(file: string) { + return new Promise((resolve, reject) => this.sftp!.stat(file.replace(/\\/g, "/"), (error, stat) => { if (!error) resolve(stat.isFile()); else if (Number((error as NodeJS.ErrnoException).code) === 2) resolve(false); else reject(error); })); + } + async uploadPack(local: string) { + const filename = `${Date.now()}-${path.basename(local).replace(/[^a-zA-Z0-9_.-]/g, "_")}`; + const join = this.profile.kind === "ssh" && this.profile.os === "linux" ? path.posix.join : path.win32.join; + const directory = join(this.profile.root, "packs", "_desktop-inbox"); + if (this.profile.kind === "local") { fs.mkdirSync(directory, { recursive: true }); await fs.promises.copyFile(local, join(directory, filename)); } + else { + await this.execRemote(this.profile.os === "windows" ? powershell(`New-Item -ItemType Directory -Force (${psValue(directory)}) | Out-Null`) : `mkdir -p ${shellQuote(directory)}`); + await this.upload(local, join(directory, filename)); + } + return { filename }; + } + disconnect() { this.client?.disconnect(); this.ssh?.end(); this.child?.stdin?.end(); this.credentials = {}; } +} diff --git a/packages/desktop/src/renderer/app/App.tsx b/packages/desktop/src/renderer/app/App.tsx new file mode 100644 index 00000000..b2b48e72 --- /dev/null +++ b/packages/desktop/src/renderer/app/App.tsx @@ -0,0 +1,199 @@ +/** + * App.tsx — 应用根组件与外壳布局(SnowUI Dashboard 三栏:侧栏 / 内容 / 右侧动态栏) + * + * 使用场景:main.tsx 渲染 。负责: + * - 组合全局 Provider:外观 → 提示 → 确认框 → 实例状态,以及全局通知出口; + * - 外壳布局与可折叠的侧栏、右侧动态栏(偏好持久化在 localStorage); + * - 全局快捷键:Ctrl+K 命令面板、Ctrl+B 侧栏、Ctrl+1…8 切换页面; + * - 根据实例状态选择欢迎页 / 连接门 / 工作区页面,并挂载实例设置、接入流程与关于对话框。 + * 页面由 PAGES 注册表按 PageKey 查找(OCP:新增页面只需在 nav.ts 与此表各加一项)。 + */ +import { useCallback, useEffect, useState, type ComponentType } from "react"; +import { CommandPalette } from "../components/CommandPalette.js"; +import { Button } from "../components/controls.js"; +import { ConfirmProvider, ToastHost } from "../components/feedback.js"; +import { ConnectGate, Welcome } from "../components/Gates.js"; +import { RightRail } from "../components/RightRail.js"; +import { Sidebar } from "../components/Sidebar.js"; +import { TitleBar } from "../components/TitleBar.js"; +import { TooltipProvider } from "../components/tooltip.js"; +import { Callout } from "../components/ui.js"; +import { AboutDialog } from "../dialogs/AboutDialog.js"; +import { FlowDialogs } from "../dialogs/FlowDialogs.js"; +import { ProfileDialog } from "../dialogs/ProfileDialog.js"; +import { cx } from "../lib/cx.js"; +import { ConfigPage } from "../pages/ConfigPage.js"; +import { ConsolePage } from "../pages/ConsolePage.js"; +import { ModulesPage } from "../pages/ModulesPage.js"; +import { OverviewPage } from "../pages/OverviewPage.js"; +import { PacksPage } from "../pages/PacksPage.js"; +import { PlayersPage } from "../pages/PlayersPage.js"; +import { TasksPage } from "../pages/TasksPage.js"; +import { UpdatesPage } from "../pages/UpdatesPage.js"; +import { DesktopProvider, useDesktop } from "./desktop.js"; +import { NAV, type PageKey } from "./nav.js"; +import { AppearanceProvider } from "./theme.js"; + +/** 页面注册表:PageKey → 页面组件 */ +const PAGES: Record = { + overview: OverviewPage, + logs: ConsolePage, + tasks: TasksPage, + modules: ModulesPage, + packs: PacksPage, + config: ConfigPage, + players: PlayersPage, + updates: UpdatesPage, +}; + +/** 布局偏好的本地持久化键 */ +const LAYOUT_KEY = "sfmc.desktop.layout"; +/** 窗口宽度低于此值时右侧动态栏改为浮层(避免内容区过窄) */ +const NARROW_QUERY = "(max-width: 1279px)"; + +/** 读取持久化的布局偏好 */ +function readLayout(): { sidebar: boolean; rail: boolean } { + try { + const value = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? "{}") as { sidebar?: boolean; rail?: boolean }; + return { sidebar: value.sidebar !== false, rail: value.rail === true }; + } catch { + return { sidebar: true, rail: false }; + } +} + +/** 订阅媒体查询(用于窄窗口下的布局切换) */ +function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => window.matchMedia(query).matches); + useEffect(() => { + const media = window.matchMedia(query); + const listener = (event: MediaQueryListEvent) => setMatches(event.matches); + media.addEventListener("change", listener); + return () => media.removeEventListener("change", listener); + }, [query]); + return matches; +} + +/** 工作区顶部提示:连接消息与只读状态(与原实现的两条 Alert 一致,并补充直接可执行的操作) */ +function WorkspaceBanners() { + const { model, selected, editable, connect, startAttach, guarded } = useDesktop(); + if (!selected || !model.handshake) return null; + const readonly = !editable; + return ( +
+ {model.connectionMessage && ( + void connect(selected, true)}>恢复连接} + > + {model.connectionMessage} + + )} + {readonly && !model.disconnected && ( + void guarded(() => startAttach(selected))}>完成接入} + > + {model.handshake.legacy ? "旧版平台,仅支持查看。升级后可执行管理操作。" : "只读连接。接入后可修改此实例。"} + + )} +
+ ); +} + +/** 内容区:按实例状态选择欢迎页 / 连接门 / 页面 */ +function Workspace() { + const { ready, profiles, selected, model, page, current } = useDesktop(); + if (!ready) return
; + if (!profiles.length || !selected) return ; + if (!model.handshake) return ; + const Page = PAGES[page]; + return ( + <> + + + + ); +} + +/** 外壳:三栏布局、快捷键与全局对话框 */ +function Shell() { + const { selected, setPage, appInfo } = useDesktop(); + const [layout, setLayout] = useState(readLayout); + const narrow = useMediaQuery(NARROW_QUERY); + /** 窄窗口下右侧栏以浮层打开(不持久化) */ + const [railOverlay, setRailOverlay] = useState(false); + const [paletteOpen, setPaletteOpen] = useState(false); + const [aboutOpen, setAboutOpen] = useState(false); + useEffect(() => localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout)), [layout]); + useEffect(() => setRailOverlay(false), [narrow]); + + const railOpen = narrow ? railOverlay : layout.rail; + const toggleSidebar = useCallback(() => setLayout((value) => ({ ...value, sidebar: !value.sidebar })), []); + const toggleRail = useCallback(() => { + if (narrow) setRailOverlay((value) => !value); + else setLayout((value) => ({ ...value, rail: !value.rail })); + }, [narrow]); + const openAbout = useCallback(() => setAboutOpen(true), []); + + useEffect(() => { + const listener = (event: KeyboardEvent) => { + if (!(event.ctrlKey || event.metaKey) || event.altKey) return; + const key = event.key.toLowerCase(); + if (key === "k") { + event.preventDefault(); + setPaletteOpen((value) => !value); + } else if (key === "b" && !event.shiftKey) { + event.preventDefault(); + toggleSidebar(); + } else if (/^[1-9]$/.test(key) && selected) { + const item = NAV[Number(key) - 1]; + if (item) { + event.preventDefault(); + setPage(item.key); + } + } + }; + window.addEventListener("keydown", listener); + return () => window.removeEventListener("keydown", listener); + }, [selected, setPage, toggleSidebar]); + + return ( +
+ +
+ setPaletteOpen(true)} railOpen={railOpen} onToggleRail={toggleRail} /> +
+ +
+
+ {railOpen && ( + <> + {narrow &&
setRailOverlay(false)} aria-hidden="true" />} + + + )} + setPaletteOpen(false)} onOpenAbout={openAbout} onToggleSidebar={toggleSidebar} onToggleRail={toggleRail} /> + + + setAboutOpen(false)} /> +
+ ); +} + +/** 应用根组件 */ +export function App() { + return ( + + + + + + + + + + + ); +} diff --git a/packages/desktop/src/renderer/app/desktop.tsx b/packages/desktop/src/renderer/app/desktop.tsx new file mode 100644 index 00000000..ca42ca3e --- /dev/null +++ b/packages/desktop/src/renderer/app/desktop.tsx @@ -0,0 +1,559 @@ +/** + * desktop.tsx — 实例状态与管理操作的唯一权威来源(DesktopProvider / useDesktop) + * + * 使用场景:应用根部提供一次,所有页面、对话框、命令面板通过 useDesktop() 读取当前实例模型并调用操作, + * 页面不直接访问 window.sfmc 的连接细节(DIP)。连接、刷新、轮询、任务等待等流程沿用原实现的语义, + * 仅把原先内嵌在 modal.confirm 中的界面拆成独立对话框(通过 flow 状态驱动)。 + */ +import type { + AttachmentPlan, + Handshake, + LogEntryWire, + ManagementMethod, + ManagementMethodMap, + ModuleRow, + OperationRecord, + PackRow, + ServiceStateChanged, + ServiceStatusRow, +} from "@sfmc-bds/management"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import type { AppInfo, Credentials, InstanceProfile } from "../../shared/api.js"; +import { toast, useConfirm } from "../components/feedback.js"; +import { errorText, isTaskDone, phaseLabel } from "../lib/format.js"; +import type { PageKey } from "./nav.js"; + +/** 单个实例在客户端内的完整视图模型(与原实现字段一致,新增 connecting / connectError 供连接门展示) */ +export interface Model { + approved?: boolean; + handshake?: Handshake; + services: ServiceStatusRow[]; + logs: LogEntryWire[]; + modules: ModuleRow[]; + packs: PackRow[]; + tasks: OperationRecord[]; + configKeys: string[]; + attached?: boolean; + disconnected?: boolean; + connectionMessage?: string; + /** 正在建立连接(连接门按钮的加载态) */ + connecting?: boolean; + /** 最近一次连接失败的原因(连接门内联展示) */ + connectError?: string; +} +/** 空模型 */ +export const blank = (): Model => ({ services: [], logs: [], modules: [], packs: [], tasks: [], configKeys: [] }); + +/** + * 需要用户参与的接入流程:SSH 登录、初始化新部署、接入/升级计划。 + * 使用场景:connect() 根据握手结果设置 flow,App 根据 flow 渲染对应对话框。 + */ +export type Flow = + | { kind: "login"; profile: InstanceProfile; reconnect: boolean } + | { kind: "deploy"; profile: InstanceProfile } + | { kind: "attach"; profile: InstanceProfile; plan: AttachmentPlan }; + +/** 提交后台任务时的确认选项 */ +export interface SubmitOptions { + /** 确认框中的补充说明(替换默认影响说明的首段) */ + description?: ReactNode; + /** 危险操作:确认按钮为红色 */ + danger?: boolean; + /** 确认按钮文案(默认"执行") */ + okText?: string; + /** 强确认:确认框内显示此文案的复选框,勾选后才能执行(用于恢复备份等会丢弃数据的操作) */ + acknowledge?: string; +} + +/** 绑定到当前实例的请求函数类型 */ +export type RequestFn = (method: K, params?: unknown) => Promise; +/** 提交后台任务函数类型:确认后提交,返回任务编号(取消或失败返回 undefined) */ +export type SubmitFn = (method: ManagementMethod, params: unknown, title: string, options?: SubmitOptions) => Promise; + +interface DesktopValue { + /** 实例列表已从主进程读取完成(避免启动瞬间误显示首启欢迎页) */ + ready: boolean; + profiles: InstanceProfile[]; + current: string; + setCurrent: (id: string) => void; + selected: InstanceProfile | undefined; + models: Record; + model: Model; + page: PageKey; + setPage: (page: PageKey) => void; + busy: boolean; + /** 包裹异步操作:显示全局进度、捕获错误并提示 */ + guarded: (action: () => Promise) => Promise; + patch: (id: string, values: Partial) => void; + refresh: (id: string) => Promise; + request: RequestFn; + submit: SubmitFn; + /** 是否允许写操作:已接入、未断开、非旧版协议 */ + editable: boolean; + connect: (profile: InstanceProfile, reconnect?: boolean, secret?: Credentials) => Promise; + disconnect: (profile: InstanceProfile) => Promise; + /** 重新读取接入计划并打开接入对话框(用于"仅查看"后继续接入) */ + startAttach: (profile: InstanceProfile) => Promise; + /** 初始化新部署(接受条款 + 端口):任务受理后回调 onAccepted 供界面展示进度,随后等待任务完成 */ + createDeployment: (profile: InstanceProfile, ports: { dbPort: number; bdsPort: number; bdsPort6: number }, onAccepted?: (operationId: string) => void) => Promise; + /** 执行接入计划(升级时转入任务页,否则等待完成并标记已接入) */ + applyAttachment: (profile: InstanceProfile, plan: AttachmentPlan) => Promise; + confirmHost: (profile: InstanceProfile) => Promise; + flow: Flow | null; + setFlow: (flow: Flow | null) => void; + profileDialog: ProfileDialogState; + /** 打开实例设置对话框;preset 用于新建时预选连接方式(欢迎页的两张入口卡片) */ + openProfileDialog: (editing: InstanceProfile | null, preset?: InstanceProfile["kind"]) => void; + closeProfileDialog: () => void; + saveProfile: (values: InstanceProfile & Credentials, editing: InstanceProfile | null) => Promise; + removeProfile: (profile: InstanceProfile) => Promise; + appInfo: AppInfo | undefined; +} + +/** 实例设置对话框状态:editing 为空表示新建 */ +export interface ProfileDialogState { + open: boolean; + editing: InstanceProfile | null; + preset?: InstanceProfile["kind"]; +} + +const DesktopContext = createContext(null); +/** 上次选中实例的本地持久化键 */ +const LAST_INSTANCE_KEY = "sfmc.desktop.lastInstance"; + +/** 读取桌面上下文(必须位于 DesktopProvider 内) */ +export function useDesktop(): DesktopValue { + const value = useContext(DesktopContext); + if (!value) throw new Error("useDesktop 必须在 DesktopProvider 内使用"); + return value; +} + +/** 正在被通知面板跟踪的任务:记录所属实例、标题与上次渲染的进度指纹 */ +interface TrackedOperation { + instanceId: string; + title: string; + fingerprint: string; + /** 用户手动关闭了进度卡片:之后不再弹出进度,但仍会提示最终结果 */ + silenced?: boolean; +} + +/** 提交任务确认框中的固定提示(与原实现文案一致) */ +const SUBMIT_NOTE = "后台执行期间可能重载或重启受影响服务;任务提交后关闭客户端不会取消执行。"; + +export function DesktopProvider({ children }: { children: ReactNode }) { + const confirm = useConfirm(); + const [ready, setReady] = useState(false); + const [profiles, setProfiles] = useState([]); + const [current, setCurrentState] = useState(""); + const [models, setModels] = useState>({}); + const [page, setPage] = useState("overview"); + const [busyCount, setBusyCount] = useState(0); + const [flow, setFlow] = useState(null); + const [profileDialog, setProfileDialog] = useState({ open: false, editing: null }); + const [appInfo, setAppInfo] = useState(); + const modelsRef = useRef(models); + modelsRef.current = models; + const profilesRef = useRef(profiles); + profilesRef.current = profiles; + const tracked = useRef(new Map()); + /** 待合并的实时日志(按实例),每 120ms 批量写入一次,避免高频日志导致整树重渲染 */ + const pendingLogs = useRef(new Map()); + const flushTimer = useRef | undefined>(undefined); + + const selected = profiles.find((profile) => profile.id === current); + const model = models[current] ?? blank(); + const setCurrent = useCallback((id: string) => { + setCurrentState(id); + if (id) localStorage.setItem(LAST_INSTANCE_KEY, id); + }, []); + + const patch = useCallback( + (id: string, values: Partial) => + setModels((previous) => ({ ...previous, [id]: { ...(previous[id] ?? blank()), ...values } })), + [] + ); + const request = useCallback( + (method, params = {}) => window.sfmc.request(current, method, params), + [current] + ); + const guarded = useCallback( + async (action: () => Promise): Promise => { + setBusyCount((count) => count + 1); + try { + return await action(); + } catch (error) { + toast.error("操作失败", errorText(error)); + return undefined; + } finally { + setBusyCount((count) => count - 1); + } + }, + [] + ); + const refresh = useCallback( + async (id: string) => { + const results = await Promise.allSettled([ + window.sfmc.request(id, "services.list"), + window.sfmc.request(id, "operations.list"), + window.sfmc.request(id, "modules.list"), + window.sfmc.request(id, "config.list"), + window.sfmc.request(id, "packs.list"), + ]); + const values: Partial = {}; + if (results[0]?.status === "fulfilled") values.services = results[0].value.rows; + if (results[1]?.status === "fulfilled") values.tasks = results[1].value.operations; + if (results[2]?.status === "fulfilled") values.modules = results[2].value.modules; + if (results[3]?.status === "fulfilled") values.configKeys = results[3].value.keys; + if (results[4]?.status === "fulfilled") values.packs = results[4].value.packs; + patch(id, values); + }, + [patch] + ); + + // 初始化:读取实例列表与客户端信息,订阅事件推送与连接状态,启动 5 秒轮询 + useEffect(() => { + void window.sfmc + .profiles() + .then((rows) => { + setProfiles(rows); + const last = localStorage.getItem(LAST_INSTANCE_KEY); + const initial = rows.find((row) => row.id === last) ?? rows[0]; + if (initial) setCurrentState(initial.id); + }) + .finally(() => setReady(true)); + void window.sfmc.appInfo().then(setAppInfo).catch(() => {}); + const flushLogs = () => { + flushTimer.current = undefined; + const batch = pendingLogs.current; + pendingLogs.current = new Map(); + setModels((previous) => { + const next = { ...previous }; + for (const [id, entries] of batch) { + const value = next[id] ?? blank(); + next[id] = { ...value, logs: [...value.logs, ...entries].slice(-5000) }; + } + return next; + }); + }; + const removeEvent = window.sfmc.onEvent((id, event) => { + if (event.event === "log") { + const queue = pendingLogs.current.get(id) ?? []; + queue.push(event.payload as LogEntryWire); + pendingLogs.current.set(id, queue); + flushTimer.current ??= setTimeout(flushLogs, 120); + return; + } + setModels((previous) => { + const value = { ...(previous[id] ?? blank()) }; + if (event.event === "operation") { + const operation = event.payload as OperationRecord; + value.tasks = [operation, ...value.tasks.filter((row) => row.id !== operation.id)].slice(0, 200); + } + if (event.event === "serviceState") { + const state = event.payload as ServiceStateChanged; + value.services = value.services.map((row) => + row.name === state.name ? { ...row, running: state.running, pid: state.pid, ...(state.running ? {} : { uptime: "—" }) } : row + ); + } + return { ...previous, [id]: value }; + }); + }); + const removeConnection = window.sfmc.onConnection((id, connectionMessage) => + id + ? patch(id, { + connectionMessage, + ...(/断开|结束|关闭/.test(connectionMessage) ? { disconnected: true, attached: false } : {}), + }) + : void toast.error(connectionMessage) + ); + const timer = setInterval(() => { + for (const [id, value] of Object.entries(modelsRef.current)) + if (value.handshake && !value.disconnected) void refresh(id); + }, 5000); + return () => { + removeEvent(); + removeConnection(); + clearInterval(timer); + if (flushTimer.current) clearTimeout(flushTimer.current); + }; + }, [patch, refresh]); + + const waitOperation = useCallback( + async (id: string, operationId: string) => { + for (;;) { + const { operation } = await window.sfmc.request(id, "operations.get", { operationId }); + patch(id, { + tasks: [operation, ...(modelsRef.current[id]?.tasks ?? []).filter((row) => row.id !== operationId)], + }); + if (["succeeded", "failed", "interrupted"].includes(operation.status)) { + if (operation.status !== "succeeded") throw new Error(operation.error?.message ?? "后台任务未完成"); + await refresh(id); + return operation; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + }, + [patch, refresh] + ); + + /** 开始在通知面板中跟踪一个任务 */ + const track = useCallback((instanceId: string, operationId: string, title: string) => { + tracked.current.set(operationId, { instanceId, title, fingerprint: "" }); + }, []); + + // 任务通知:每当模型中的任务列表变化(事件推送、轮询或 waitOperation),刷新被跟踪任务的通知卡片 + useEffect(() => { + for (const [operationId, entry] of tracked.current) { + const record = models[entry.instanceId]?.tasks.find((row) => row.id === operationId); + if (!record) continue; + const lastPhase = record.phases.at(-1); + const fingerprint = `${record.status}:${record.phases.length}:${lastPhase?.status ?? ""}`; + if (fingerprint === entry.fingerprint) continue; + entry.fingerprint = fingerprint; + const instanceName = profilesRef.current.find((row) => row.id === entry.instanceId)?.name ?? ""; + const done = isTaskDone(record.status); + const view = { + label: "查看任务", + onClick: () => { + setCurrent(entry.instanceId); + setPage("tasks"); + toast.close(operationId); + }, + }; + if (!done) { + if (entry.silenced) continue; + toast.show({ + id: operationId, + tone: "progress", + title: entry.title, + meta: instanceName, + description: lastPhase ? phaseLabel(lastPhase.name) : "等待执行", + timeout: 0, + action: view, + onClose: () => { + const current = tracked.current.get(operationId); + if (current) current.silenced = true; + }, + }); + continue; + } + tracked.current.delete(operationId); + if (record.status === "succeeded") { + toast.show({ id: operationId, tone: "success", title: `${entry.title} 已完成`, meta: instanceName, timeout: 4500 }); + } else { + toast.show({ + id: operationId, + tone: "danger", + title: `${entry.title} ${record.status === "failed" ? "失败" : "已中断"}`, + meta: instanceName, + description: record.error?.message ?? "后台任务未完成", + timeout: 0, + action: view, + }); + } + } + }, [models, setCurrent]); + + /** + * 提交后台任务:确认影响 → 提交 → 通知卡片跟踪进度(不强制跳转任务页)。 + * 提交请求在确认框内执行:失败时错误留在确认框中,用户可重试或取消;取消或失败返回 undefined。 + */ + const submit = useCallback( + async (method, params, title, options = {}) => { + const instanceId = current; + let operationId: string | undefined; + const confirmed = await confirm({ + title, + description: options.description, + note: SUBMIT_NOTE, + okText: options.okText ?? "执行", + cancelText: "取消", + danger: options.danger, + acknowledge: options.acknowledge, + icon: options.danger ? "warning" : "zap", + onConfirm: async () => { + const accepted = (await window.sfmc.request(instanceId, method, params)) as { operationId?: string }; + if (accepted.operationId) { + track(instanceId, accepted.operationId, title); + await refresh(instanceId); + } + operationId = accepted.operationId; + }, + }); + return confirmed ? operationId : undefined; + }, + [current, confirm, refresh, track] + ); + + const startAttach = useCallback( + async (profile: InstanceProfile) => { + const plan = await window.sfmc.request(profile.id, "attachment.plan"); + if (plan.development) { + patch(profile.id, { attached: true, approved: true }); + return; + } + setFlow({ kind: "attach", profile, plan }); + }, + [patch] + ); + + const connect = useCallback( + async (profile: InstanceProfile, reconnect = false, secret?: Credentials) => { + setCurrent(profile.id); + if (profile.kind === "ssh" && !profile.hasCredential && !secret) { + setFlow({ kind: "login", profile, reconnect }); + return; + } + patch(profile.id, { connecting: true, connectError: "" }); + await guarded(async () => { + try { + const handshake = await window.sfmc.connect(profile.id, secret); + if (secret) setProfiles(await window.sfmc.profiles()); + const wasAttached = modelsRef.current[profile.id]?.attached || modelsRef.current[profile.id]?.approved; + if (handshake.host.arch !== "x64" || !["windows", "linux"].includes(handshake.host.os)) throw new Error("v1 仅支持 Windows/Linux x64 宿主"); + patch(profile.id, { handshake, disconnected: false, connectionMessage: "", attached: reconnect && Boolean(wasAttached) && !handshake.legacy }); + await refresh(profile.id); + const history = await window.sfmc.request(profile.id, "logs.tail", { limit: 1000 }); + patch(profile.id, { logs: history.entries }); + if (reconnect) return; + if (!handshake.initialized) { + setFlow({ kind: "deploy", profile }); + return; + } + await startAttach(profile); + } catch (error) { + patch(profile.id, { connectError: errorText(error) }); + throw error; + } finally { + patch(profile.id, { connecting: false }); + } + }); + }, + [guarded, patch, refresh, setCurrent, startAttach] + ); + + const disconnect = useCallback( + async (profile: InstanceProfile) => { + await guarded(async () => { + await window.sfmc.disconnect(profile.id); + patch(profile.id, { disconnected: true, attached: false, connectionMessage: "已手动断开连接;服务器和后台任务继续运行" }); + }); + }, + [guarded, patch] + ); + + const createDeployment = useCallback( + async (profile: InstanceProfile, ports: { dbPort: number; bdsPort: number; bdsPort6: number }, onAccepted?: (operationId: string) => void) => { + const accepted = await window.sfmc.request(profile.id, "deployment.create", { acceptEula: true, ...ports }); + onAccepted?.(accepted.operationId); + await waitOperation(profile.id, accepted.operationId); + patch(profile.id, { attached: true, approved: true }); + }, + [patch, waitOperation] + ); + + const applyAttachment = useCallback( + async (profile: InstanceProfile, plan: AttachmentPlan) => { + const accepted = await window.sfmc.request(profile.id, "attachment.apply"); + if (plan.upgradeRequired) { + toast.info("升级任务已提交", "完成后重新连接查看结果"); + track(profile.id, accepted.operationId, `升级平台至 ${plan.targetVersion}`); + setPage("tasks"); + } else { + await waitOperation(profile.id, accepted.operationId); + patch(profile.id, { attached: true, approved: true }); + } + }, + [patch, track, waitOperation] + ); + + const confirmHost = useCallback( + async (profile: InstanceProfile) => { + await guarded(async () => { + if (await window.sfmc.confirmHost(profile.id)) toast.success("已确认新身份", "请重新接入实例"); + }); + }, + [guarded] + ); + + const saveProfile = useCallback( + async (values: InstanceProfile & Credentials, editing: InstanceProfile | null) => { + const { password, passphrase, ...valuesWithoutSecrets } = values; + const rows = await window.sfmc.saveProfile({ ...valuesWithoutSecrets, id: editing?.id ?? "" }, { password, passphrase }); + setProfiles(rows); + const row = rows.at(-1)!; + setCurrent(row.id); + setProfileDialog({ open: false, editing: null }); + }, + [setCurrent] + ); + + const removeProfile = useCallback( + async (profile: InstanceProfile) => { + const rows = await window.sfmc.removeProfile(profile.id); + setProfiles(rows); + setModels((previous) => { + const next = { ...previous }; + delete next[profile.id]; + return next; + }); + setProfileDialog({ open: false, editing: null }); + setCurrent(rows[0]?.id ?? ""); + }, + [setCurrent] + ); + + const editable = Boolean(model.attached && !model.disconnected && !model.handshake?.legacy); + const value = useMemo( + () => ({ + ready, + profiles, + current, + setCurrent, + selected, + models, + model, + page, + setPage, + busy: busyCount > 0, + guarded, + patch, + refresh, + request, + submit, + editable, + connect, + disconnect, + startAttach, + createDeployment, + applyAttachment, + confirmHost, + flow, + setFlow, + profileDialog, + openProfileDialog: (editing, preset) => setProfileDialog({ open: true, editing, ...(preset ? { preset } : {}) }), + closeProfileDialog: () => setProfileDialog({ open: false, editing: null }), + saveProfile, + removeProfile, + appInfo, + }), + [ready, profiles, current, setCurrent, selected, models, model, page, busyCount, guarded, patch, refresh, request, submit, editable, connect, disconnect, startAttach, createDeployment, applyAttachment, confirmHost, flow, profileDialog, saveProfile, removeProfile, appInfo] + ); + return {children}; +} + +/** + * 实例连接状态的展示语义(状态点语气 + 文案)。 + * 使用场景:侧边栏实例切换器、标题栏连接胶囊、命令面板的实例列表。 + */ +export function connectionState(model: Model | undefined): { tone: "success" | "warning" | "info" | "neutral"; label: string; online: boolean } { + if (model?.connecting) return { tone: "info", label: "连接中", online: false }; + if (model?.handshake && !model.disconnected) return { tone: "success", label: model.attached ? "已接入" : "已连接(只读)", online: true }; + if (model?.handshake && model.disconnected) return { tone: "warning", label: "连接已断开", online: false }; + return { tone: "neutral", label: "未连接", online: false }; +} + +/** 当前实例中仍在进行的任务(排队或运行中),用于标题栏任务指示与侧边栏计数 */ +export function activeTasks(model: Model): OperationRecord[] { + return model.tasks.filter((task) => !isTaskDone(task.status)); +} diff --git a/packages/desktop/src/renderer/app/nav.ts b/packages/desktop/src/renderer/app/nav.ts new file mode 100644 index 00000000..ddc2e7c8 --- /dev/null +++ b/packages/desktop/src/renderer/app/nav.ts @@ -0,0 +1,38 @@ +/** + * nav.ts — 工作区导航注册表 + * + * 使用场景:侧边栏导航、标题栏面包屑、命令面板与快捷键(Ctrl+1…8)都从这里读取页面元数据。 + * 新增页面只需追加一项并在 App 的页面表中注册组件(OCP),能力门控与原实现的 capNames 映射一致。 + */ +import type { ManagementCapability } from "@sfmc-bds/management"; +import type { IconName } from "../components/icons.js"; + +/** 页面标识(与原实现的 NAV key 保持一致) */ +export type PageKey = "overview" | "logs" | "modules" | "config" | "packs" | "players" | "updates" | "tasks"; + +/** 单个导航项 */ +export interface NavItem { + key: PageKey; + label: string; + icon: IconName; + /** 侧边栏分组名 */ + group: "运行" | "内容" | "维护"; + /** 握手能力列表中必须包含的能力,否则入口禁用 */ + capability: ManagementCapability; + /** 命令面板中的辅助检索词 */ + keywords: string; +} + +export const NAV: NavItem[] = [ + { key: "overview", label: "服务总览", icon: "overview", group: "运行", capability: "services", keywords: "overview services 服务 状态 启动 停止" }, + { key: "logs", label: "日志控制台", icon: "terminal", group: "运行", capability: "logs", keywords: "logs console 日志 控制台 命令" }, + { key: "tasks", label: "任务记录", icon: "history", group: "运行", capability: "operations", keywords: "tasks operations 任务 进度 记录" }, + { key: "modules", label: "模块管理", icon: "modules", group: "内容", capability: "modules", keywords: "modules 模块 插件 安装" }, + { key: "packs", label: "世界包", icon: "package", group: "内容", capability: "packs", keywords: "packs 行为包 资源包 addon" }, + { key: "config", label: "配置", icon: "sliders", group: "内容", capability: "config", keywords: "config 配置 server.properties" }, + { key: "players", label: "玩家与权限", icon: "users", group: "维护", capability: "players", keywords: "players 玩家 权限 允许名单 allowlist" }, + { key: "updates", label: "更新与备份", icon: "refresh", group: "维护", capability: "updates", keywords: "updates backups 更新 升级 备份 恢复" }, +]; + +/** 按 key 取导航项 */ +export const navItem = (key: PageKey) => NAV.find((item) => item.key === key)!; diff --git a/packages/desktop/src/renderer/app/theme.tsx b/packages/desktop/src/renderer/app/theme.tsx new file mode 100644 index 00000000..7c3bcf4c --- /dev/null +++ b/packages/desktop/src/renderer/app/theme.tsx @@ -0,0 +1,307 @@ +/** + * theme.tsx — SnowUI 设计令牌、字体与外观(明暗主题)提供者 + * + * 使用场景:应用根节点包裹 。调色板在本文件中只定义一次(DRY), + * 同时派生出:① 写入 :root 的 CSS 变量(styles/*.css 使用);② Monaco 编辑器主题;③ 主进程标题栏覆盖层配色。 + * 基于 SnowUI Dashboard UI Kit(ByeWind,CC BY 4.0):单色主按钮与柔和色块, + * 深色采用炭灰底色与低饱和强调色。外观模式持久化在 localStorage,并通过 window.sfmc.appearance 同步给主进程。 + */ +import "@fontsource-variable/inter"; +import "@fontsource-variable/jetbrains-mono"; +import "@fontsource-variable/noto-sans-sc"; +import cjkPunctuationUrl from "@fontsource-variable/noto-sans-sc/files/noto-sans-sc-latin-wght-normal.woff2?url"; +import * as monaco from "monaco-editor"; +import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useState, type ReactNode } from "react"; +import type { AppearanceMode } from "../../shared/api.js"; + +/** 一套主题的全部颜色令牌(键名转为 kebab-case 后写成 --c-* 变量) */ +interface Palette { + /** Background/1:窗口、侧栏、标题栏与内容区底色 */ + bg: string; + /** 侧栏与标题栏底色 */ + chrome: string; + /** 对话框等浮起的表面 */ + elevated: string; + /** Background/2:区块(Block)与卡片底色 */ + block: string; + /** 图标与信息色块,文字随主题调整 */ + pastel1: string; + pastel2: string; + onPastel: string; + /** 文字层级:100% / 64%(说明文字)/ 40%(元信息)/ 20%(禁用、分隔符) */ + text: string; + text2: string; + text3: string; + text4: string; + /** 0.5px 发丝线(Black 10%) */ + line: string; + /** 浅填充(Black 4%):搜索框、选中导航、次级按钮 */ + fill: string; + /** 较强填充:悬停/按下 */ + fill2: string; + /** 主按钮(单色:浅色为近黑,深色为白) */ + primary: string; + primaryHover: string; + onPrimary: string; + /** 强调色:焦点环、链接、进度条、图表主线 */ + accent: string; + ring: string; + /** 反色面:提示气泡 */ + inverse: string; + onInverse: string; + /** 毛玻璃浮层底色(配合 backdrop-filter: blur(40px)) */ + glass: string; + /** 对话框遮罩 */ + scrim: string; + /** 浮层阴影 */ + shadowPop: string; + /** 文本选区 */ + selection: string; + /** 状态语气:实色(状态点/图标)、文字色(满足对比度)、柔和底色 */ + success: string; + successText: string; + successSoft: string; + info: string; + infoText: string; + infoSoft: string; + progress: string; + progressText: string; + progressSoft: string; + warning: string; + warningText: string; + warningSoft: string; + danger: string; + dangerText: string; + dangerSoft: string; +} + +/** SnowUI 次级色板(图表与日志来源色),两套主题共用 */ +export const CHART_COLORS = ["#a0bce8", "#6be6d3", "#7dbbff", "#b899eb", "#71dd8c", "#9f9ff8", "#ffc555", "#ff9f87"] as const; + +/** 浅色与深色两套调色板 */ +export const PALETTES: Record<"light" | "dark", Palette> = { + light: { + bg: "#f5f5f6", + chrome: "#f5f5f6", + elevated: "#f5f5f6", + block: "#ffffff", + pastel1: "#e6f1fd", + pastel2: "#edeefc", + onPastel: "#1c1c1c", + text: "#1c1c1c", + text2: "rgba(28, 28, 28, 0.64)", + text3: "rgba(28, 28, 28, 0.55)", + text4: "rgba(28, 28, 28, 0.2)", + line: "rgba(28, 28, 28, 0.1)", + fill: "rgba(28, 28, 28, 0.04)", + fill2: "rgba(28, 28, 28, 0.08)", + primary: "#1c1c1c", + primaryHover: "#3a3a3a", + onPrimary: "#ffffff", + accent: "#1c1c1c", + ring: "rgba(28, 28, 28, 0.16)", + inverse: "#1c1c1c", + onInverse: "#ffffff", + glass: "rgba(255, 255, 255, 0.82)", + scrim: "rgba(28, 28, 28, 0.16)", + shadowPop: "0 0 0 0.5px rgba(28, 28, 28, 0.1), 0 4px 12px rgba(28, 28, 28, 0.05), 0 16px 40px rgba(28, 28, 28, 0.08)", + selection: "rgba(138, 140, 217, 0.28)", + success: "#4aa785", + successText: "#2e7d60", + successSoft: "rgba(74, 167, 133, 0.12)", + info: "#59a8d4", + infoText: "#2f7aa6", + infoSoft: "rgba(89, 168, 212, 0.14)", + progress: "#8a8cd9", + progressText: "#5b5ec2", + progressSoft: "rgba(138, 140, 217, 0.16)", + warning: "#ffc555", + warningText: "#9a6200", + warningSoft: "rgba(255, 197, 85, 0.2)", + danger: "#ff4747", + dangerText: "#d92d2d", + dangerSoft: "rgba(255, 71, 71, 0.1)", + }, + dark: { + bg: "#17191c", + chrome: "#1c1f23", + elevated: "#25292e", + block: "#202327", + pastel1: "#253849", + pastel2: "#303047", + onPastel: "#dfe5f5", + text: "#e8eaed", + text2: "#b5bac1", + text3: "#9299a3", + text4: "#626b76", + line: "rgba(232, 234, 237, 0.1)", + fill: "rgba(232, 234, 237, 0.055)", + fill2: "rgba(232, 234, 237, 0.095)", + primary: "#e1e5eb", + primaryHover: "#f1f3f6", + onPrimary: "#202327", + accent: "#b5b8ed", + ring: "rgba(181, 184, 237, 0.22)", + inverse: "#2c3138", + onInverse: "#e8eaed", + glass: "rgba(37, 41, 46, 0.96)", + scrim: "rgba(0, 0, 0, 0.56)", + shadowPop: "0 0 0 1px rgba(232, 234, 237, 0.1), 0 8px 24px rgba(0, 0, 0, 0.24), 0 24px 56px rgba(0, 0, 0, 0.3)", + selection: "rgba(181, 184, 237, 0.24)", + success: "#69bc97", + successText: "#8bd4b0", + successSoft: "rgba(105, 188, 151, 0.12)", + info: "#75a9d4", + infoText: "#9ac8ed", + infoSoft: "rgba(117, 169, 212, 0.12)", + progress: "#999edb", + progressText: "#b5b8ed", + progressSoft: "rgba(153, 158, 219, 0.13)", + warning: "#dcb36b", + warningText: "#edc98d", + warningSoft: "rgba(220, 179, 107, 0.12)", + danger: "#eb7777", + dangerText: "#f39b9b", + dangerSoft: "rgba(235, 119, 119, 0.12)", + }, +}; + +/** + * 字体栈:Inter(SnowUI 规范字体)+ Noto Sans SC(中文)+ 系统中文字体兜底。 + * 最前面的 "SFMC CJK Punct" 只覆盖中文引号、破折号与省略号,让它们以全角字形显示,而不是落到 Inter 的半角字形。 + */ +const FONT_SANS = `"SFMC CJK Punct", "Inter Variable", "Noto Sans SC Variable", "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif`; +/** 等宽字体栈:JetBrains Mono + 中文兜底(日志、代码、编号) */ +export const FONT_MONO = `"JetBrains Mono Variable", "Noto Sans SC Variable", "Microsoft YaHei UI", Consolas, monospace`; + +/** 注册中文标点字体(只注册一次;浏览器按 unicode-range 在用到时才下载) */ +let punctuationRegistered = false; +function registerPunctuationFont() { + if (punctuationRegistered || typeof FontFace === "undefined") return; + punctuationRegistered = true; + const face = new FontFace("SFMC CJK Punct", `url(${cjkPunctuationUrl}) format("woff2-variations")`, { + weight: "100 900", + unicodeRange: "U+2014, U+2018-2019, U+201C-201D, U+2026", + display: "swap", + }); + document.fonts.add(face); +} + +/** 把调色板写成 CSS 变量(--c-bg、--c-text-2 …),供 styles/*.css 使用 */ +function applyCssVariables(palette: Palette) { + const root = document.documentElement; + for (const [key, value] of Object.entries(palette)) { + root.style.setProperty(`--c-${key.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`).replace(/(\d)/, "-$1")}`, value); + } + CHART_COLORS.forEach((color, index) => root.style.setProperty(`--chart-${index + 1}`, color)); + root.style.setProperty("--font-sans", FONT_SANS); + root.style.setProperty("--font-mono", FONT_MONO); +} + +/** 把 rgba() 或 #rrggbb 规整为 Monaco 可接受的 #rrggbbaa */ +function toHex(color: string): string { + if (color.startsWith("#")) return color; + const [r = 0, g = 0, b = 0, a = 1] = color.match(/[\d.]+/g)!.map(Number); + const hex = (value: number) => Math.round(value).toString(16).padStart(2, "0"); + return `#${hex(r)}${hex(g)}${hex(b)}${hex(a * 255)}`; +} + +/** 注册 Monaco 编辑器的明暗主题:底色与区块(Block)一致,选区与光标沿用强调色 */ +let monacoThemesRegistered = false; +function registerMonacoThemes() { + if (monacoThemesRegistered) return; + monacoThemesRegistered = true; + for (const [name, palette, base] of [["sfmc-light", PALETTES.light, "vs"], ["sfmc-dark", PALETTES.dark, "vs-dark"]] as const) { + const light = base === "vs"; + monaco.editor.defineTheme(name, { + base, + inherit: true, + rules: [ + { token: "comment", foreground: toHex(palette.text3).slice(1, 7), fontStyle: "italic" }, + { token: "string.key.json", foreground: light ? "5b5ec2" : "adadfb" }, + { token: "string.value.json", foreground: light ? "2e7d60" : "71dd8c" }, + { token: "number", foreground: light ? "2f7aa6" : "7dbbff" }, + { token: "keyword", foreground: light ? "9a4fd0" : "b899eb" }, + ], + colors: { + "editor.background": palette.block, + "editorGutter.background": palette.block, + "editor.foreground": palette.text, + "editor.lineHighlightBackground": toHex(palette.fill), + "editor.lineHighlightBorder": "#00000000", + "editorLineNumber.foreground": toHex(palette.text4), + "editorLineNumber.activeForeground": toHex(palette.text2), + "editorCursor.foreground": palette.accent, + "editor.selectionBackground": toHex(palette.selection), + "editor.inactiveSelectionBackground": toHex(palette.fill2), + "editorIndentGuide.background1": toHex(palette.line), + "editorIndentGuide.activeBackground1": toHex(palette.text4), + "editorWidget.background": palette.bg, + "editorWidget.border": toHex(palette.line), + "editorHoverWidget.background": palette.bg, + "editorHoverWidget.border": toHex(palette.line), + "editorSuggestWidget.background": palette.bg, + "editorSuggestWidget.border": toHex(palette.line), + "editorSuggestWidget.selectedBackground": toHex(palette.fill2), + "scrollbarSlider.background": toHex(palette.fill2), + "scrollbarSlider.hoverBackground": toHex(palette.text4), + "scrollbarSlider.activeBackground": toHex(palette.text3), + "editorError.foreground": palette.danger, + "editorWarning.foreground": palette.warning, + "focusBorder": "#00000000", + }, + }); + } +} + +/** 外观上下文:当前模式、是否深色、切换方法、Monaco 主题名 */ +interface AppearanceValue { + mode: AppearanceMode; + dark: boolean; + setMode: (mode: AppearanceMode) => void; + /** 在浅色/深色之间切换(标题栏的太阳/月亮按钮使用;会脱离"跟随系统") */ + toggle: () => void; + editorTheme: string; +} +const AppearanceContext = createContext(null); +/** 外观模式的本地持久化键 */ +const STORAGE_KEY = "sfmc.desktop.appearance"; + +/** 读取外观上下文(必须位于 AppearanceProvider 内) */ +export function useAppearance(): AppearanceValue { + const value = useContext(AppearanceContext); + if (!value) throw new Error("useAppearance 必须在 AppearanceProvider 内使用"); + return value; +} + +/** + * 外观提供者:负责主题状态、CSS 变量注入、Monaco 主题注册与主进程同步。 + */ +export function AppearanceProvider({ children }: { children: ReactNode }) { + const [mode, setMode] = useState(() => { + const saved = localStorage.getItem(STORAGE_KEY); + return saved === "light" || saved === "dark" || saved === "system" ? saved : "system"; + }); + const [systemDark, setSystemDark] = useState(() => window.matchMedia("(prefers-color-scheme: dark)").matches); + useEffect(() => { + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const listener = (event: MediaQueryListEvent) => setSystemDark(event.matches); + media.addEventListener("change", listener); + return () => media.removeEventListener("change", listener); + }, []); + const dark = mode === "dark" || (mode === "system" && systemDark); + useEffect(() => { + localStorage.setItem(STORAGE_KEY, mode); + void window.sfmc.appearance(mode).catch(() => {}); + }, [mode]); + useLayoutEffect(() => { + applyCssVariables(dark ? PALETTES.dark : PALETTES.light); + document.documentElement.dataset.theme = dark ? "dark" : "light"; + document.documentElement.style.colorScheme = dark ? "dark" : "light"; + }, [dark]); + registerPunctuationFont(); + registerMonacoThemes(); + const toggle = useCallback(() => setMode(dark ? "light" : "dark"), [dark]); + const value = useMemo(() => ({ mode, dark, setMode, toggle, editorTheme: dark ? "sfmc-dark" : "sfmc-light" }), [mode, dark, toggle]); + return {children}; +} diff --git a/packages/desktop/src/renderer/components/CommandPalette.tsx b/packages/desktop/src/renderer/components/CommandPalette.tsx new file mode 100644 index 00000000..8111e2a7 --- /dev/null +++ b/packages/desktop/src/renderer/components/CommandPalette.tsx @@ -0,0 +1,181 @@ +/** + * CommandPalette.tsx — 命令面板(Ctrl+K) + * + * 使用场景:键盘优先的全局入口,可跳转页面、切换/接入实例、批量启停服务、切换外观与布局、检查客户端更新。 + * 命令列表统一在 commands 中生成,新增命令只需追加一项(OCP);执行逻辑全部委托给 useDesktop 的操作。 + * 外层使用 Base UI Dialog 提供焦点陷阱、Esc 关闭、滚动锁定与进出场动画。 + */ +import { Dialog } from "@base-ui/react/dialog"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { connectionState, useDesktop } from "../app/desktop.js"; +import { NAV } from "../app/nav.js"; +import { useAppearance } from "../app/theme.js"; +import { cx } from "../lib/cx.js"; +import { Icon, type IconName } from "./icons.js"; +import { profileSubtitle } from "./Sidebar.js"; +import { APPEARANCE_OPTIONS } from "./TitleBar.js"; +import { Kbd, StatusDot } from "./ui.js"; + +/** 单条命令 */ +interface Command { + id: string; + group: string; + label: string; + hint?: string; + icon: IconName; + keywords?: string; + disabled?: boolean; + run: () => void; + /** 右侧附加状态(实例连接点) */ + tone?: "success" | "warning" | "info" | "neutral"; +} + +/** 外观命令的辅助检索词 */ +const APPEARANCE_KEYWORDS: Record = { system: "theme appearance 主题", light: "theme light 主题 浅色", dark: "theme dark 主题 深色" }; + +/** 简单的多词子串匹配:所有空格分隔的词都需命中标签或关键词 */ +function matches(command: Command, query: string): boolean { + const haystack = `${command.label} ${command.keywords ?? ""} ${command.group}`.toLowerCase(); + return query.toLowerCase().split(/\s+/).filter(Boolean).every((word) => haystack.includes(word)); +} + +export function CommandPalette({ open, onClose, onOpenAbout, onToggleSidebar, onToggleRail }: { open: boolean; onClose: () => void; onOpenAbout: () => void; onToggleSidebar?: () => void; onToggleRail?: () => void }) { + const desktop = useDesktop(); + const { setMode } = useAppearance(); + const [query, setQuery] = useState(""); + const [cursor, setCursor] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + useEffect(() => { + if (!open) return; + setQuery(""); + setCursor(0); + }, [open]); + + const commands = useMemo(() => { + const { selected, model, editable, profiles, models } = desktop; + const rows: Command[] = NAV.map((item, index) => ({ + id: `page:${item.key}`, + group: "页面", + label: item.label, + hint: `Ctrl ${index + 1}`, + icon: item.icon, + keywords: item.keywords, + disabled: !selected, + run: () => desktop.setPage(item.key), + })); + if (selected) { + const state = connectionState(model); + if (!model.handshake || model.disconnected) { + rows.push({ id: "conn:connect", group: "实例", label: model.handshake ? `恢复连接 ${selected.name}` : `接入 ${selected.name}`, icon: "plug", keywords: "connect 连接 接入", run: () => void desktop.connect(selected, Boolean(model.handshake)) }); + } else { + rows.push({ id: "conn:reconnect", group: "实例", label: `重新连接 ${selected.name}`, icon: "refresh", keywords: "reconnect 重连", run: () => void desktop.connect(selected, true) }); + rows.push({ id: "conn:disconnect", group: "实例", label: `断开 ${selected.name}`, icon: "unplug", keywords: "disconnect 断开", run: () => void desktop.disconnect(selected) }); + if (!model.attached && state.online) rows.push({ id: "conn:attach", group: "实例", label: "完成接入以启用维护操作", icon: "shield", keywords: "attach 接入 维护", run: () => void desktop.guarded(() => desktop.startAttach(selected)) }); + } + rows.push({ id: "conn:settings", group: "实例", label: "当前实例设置", icon: "settings", keywords: "settings 设置 连接", run: () => desktop.openProfileDialog(selected) }); + } + for (const profile of profiles) { + if (profile.id === desktop.current) continue; + rows.push({ id: `switch:${profile.id}`, group: "切换实例", label: profile.name, hint: profileSubtitle(profile), icon: profile.kind === "ssh" ? "server" : "monitor", keywords: `switch 切换 ${profile.host ?? ""} ${profile.root}`, tone: connectionState(models[profile.id]).tone, run: () => desktop.setCurrent(profile.id) }); + } + rows.push({ id: "profile:add", group: "实例", label: "添加实例", icon: "plus", keywords: "add new 新建 添加", run: () => desktop.openProfileDialog(null) }); + if (selected && model.handshake) { + rows.push({ id: "svc:start-all", group: "服务", label: "启动全部服务", icon: "play", keywords: "start all 启动", disabled: !editable, run: () => void desktop.submit("services.start", { name: "all" }, "启动全部服务") }); + rows.push({ id: "svc:restart-bds", group: "服务", label: "重启 BDS", icon: "restart", keywords: "restart bds 重启", disabled: !editable, run: () => void desktop.submit("services.restart", { name: "bds" }, "重启 BDS") }); + rows.push({ id: "svc:stop-all", group: "服务", label: "停止全部服务", icon: "stop", keywords: "stop all 停止", disabled: !editable, run: () => void desktop.submit("services.stop", { name: "all" }, "停止全部服务", { danger: true, okText: "停止" }) }); + rows.push({ id: "svc:refresh", group: "服务", label: "刷新实例数据", icon: "refresh", keywords: "refresh reload 刷新", disabled: model.disconnected, run: () => void desktop.guarded(() => desktop.refresh(desktop.current)) }); + } + for (const option of APPEARANCE_OPTIONS) { + rows.push({ id: `theme:${option.value}`, group: "外观", label: `外观:${option.label}`, icon: option.icon, keywords: APPEARANCE_KEYWORDS[option.value], run: () => setMode(option.value) }); + } + if (onToggleSidebar) rows.push({ id: "layout:sidebar", group: "外观", label: "展开/收起侧栏", hint: "Ctrl B", icon: "panel", keywords: "sidebar layout 侧栏 布局", run: onToggleSidebar }); + if (onToggleRail) rows.push({ id: "layout:rail", group: "外观", label: "显示/隐藏动态栏", icon: "panel", keywords: "rail activity layout 动态 布局", run: onToggleRail }); + rows.push({ id: "app:about", group: "客户端", label: "关于与检查更新", icon: "sparkles", keywords: "update about 更新 版本", run: onOpenAbout }); + return rows; + }, [desktop, setMode, onOpenAbout, onToggleSidebar, onToggleRail]); + + const visible = useMemo(() => commands.filter((command) => !query.trim() || matches(command, query.trim())), [commands, query]); + const selectable = visible.filter((command) => !command.disabled); + + useEffect(() => { + if (cursor >= selectable.length) setCursor(Math.max(0, selectable.length - 1)); + }, [cursor, selectable.length]); + useEffect(() => { + listRef.current?.querySelector(".cmd-row.active")?.scrollIntoView({ block: "nearest" }); + }, [cursor, query]); + + const execute = (command: Command | undefined) => { + if (!command || command.disabled) return; + onClose(); + command.run(); + }; + let lastGroup = ""; + return ( + { if (!next) onClose(); }}> + + + + +
+ + { + setQuery(event.target.value); + setCursor(0); + }} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { event.preventDefault(); setCursor((value) => Math.min(value + 1, selectable.length - 1)); } + else if (event.key === "ArrowUp") { event.preventDefault(); setCursor((value) => Math.max(value - 1, 0)); } + else if (event.key === "Enter") { event.preventDefault(); execute(selectable[cursor]); } + }} + /> + Esc +
+
+ {visible.length === 0 &&
没有匹配的命令
} + {visible.map((command) => { + const header = command.group !== lastGroup ? command.group : null; + lastGroup = command.group; + const active = selectable[cursor]?.id === command.id; + return ( +
+ {header &&
{header}
} + +
+ ); + })} +
+
+ ↑↓ 选择 + Enter 执行 + CtrlK 随时打开 +
+
+
+
+
+ ); +} diff --git a/packages/desktop/src/renderer/components/Gates.tsx b/packages/desktop/src/renderer/components/Gates.tsx new file mode 100644 index 00000000..8e099c23 --- /dev/null +++ b/packages/desktop/src/renderer/components/Gates.tsx @@ -0,0 +1,75 @@ +/** + * Gates.tsx — 工作区之前的两个整页状态 + * + * Welcome:尚无任何实例时的首启引导(选择本机或 SSH 两种接入方式)。 + * ConnectGate:已选实例但尚未建立管理连接时的接入页,内联展示连接错误与主机指纹核实入口。 + */ +import { useDesktop } from "../app/desktop.js"; +import { Button } from "./controls.js"; +import { Icon, type IconName } from "./icons.js"; +import { profileSubtitle } from "./Sidebar.js"; +import { Callout } from "./ui.js"; + +/** 首启欢迎页 */ +export function Welcome() { + const { openProfileDialog } = useDesktop(); + const choices: { kind: "local" | "ssh"; icon: IconName; title: string; text: string }[] = [ + { kind: "local", icon: "monitor", title: "本机", text: "Windows 上的 SFMC 部署目录" }, + { kind: "ssh", icon: "server", title: "远程服务器", text: "Linux 或 Windows,通过 SSH 连接" }, + ]; + return ( +
+
+

添加实例

+

接入已有部署,或创建新服务器。

+
+
+ {choices.map((choice) => ( + + ))} +
+
+ ); +} + +/** 未连接实例的接入页 */ +export function ConnectGate() { + const { selected, model, connect, openProfileDialog, confirmHost } = useDesktop(); + if (!selected) return null; + const fingerprint = model.connectionMessage?.includes("主机指纹"); + return ( +
+
+
+ +
+

{selected.name}

+

{profileSubtitle(selected)}

+
+
+ {selected.kind === "ssh" &&
{selected.root}
} + {model.connectError && !fingerprint && ( + {model.connectError} + )} + {fingerprint && ( + void confirmHost(selected)}>核实主机指纹}> + {model.connectionMessage} + + )} +
+ + +
+
+
+ ); +} diff --git a/packages/desktop/src/renderer/components/MetricChart.tsx b/packages/desktop/src/renderer/components/MetricChart.tsx new file mode 100644 index 00000000..85cd3d93 --- /dev/null +++ b/packages/desktop/src/renderer/components/MetricChart.tsx @@ -0,0 +1,136 @@ +/** ECharts 时间序列:按真实采样分段,断采、未知值和重启之间不连线。 */ +import type { MetricsResult, MetricsSample } from "@sfmc-bds/management"; +import { LineChart, type LineSeriesOption } from "echarts/charts"; +import { + GridComponent, + TooltipComponent, + type GridComponentOption, + type TooltipComponentOption, +} from "echarts/components"; +import { init, use, type ComposeOption, type EChartsType } from "echarts/core"; +import { SVGRenderer } from "echarts/renderers"; +import { useEffect, useRef } from "react"; +import { PALETTES, useAppearance } from "../app/theme.js"; +import { fullTime } from "../lib/format.js"; +import { metricSegments } from "../lib/metric-segments.js"; +import { EmptyState } from "./ui.js"; + +use([LineChart, GridComponent, TooltipComponent, SVGRenderer]); +type ChartOption = ComposeOption; +type MetricField = "tps" | "onlineCount"; + +function TrendPlot({ + groups, + field, + from, + end, + minutes, +}: { + groups: MetricsSample[][]; + field: MetricField; + from: number; + end: number; + minutes: number; +}) { + const container = useRef(null); + const chart = useRef(undefined); + const { dark } = useAppearance(); + const label = field === "tps" ? "TPS" : "在线人数"; + useEffect(() => { + if (!container.current) return; + const instance = init(container.current, undefined, { renderer: "svg" }); + chart.current = instance; + const observer = new ResizeObserver(() => instance.resize()); + observer.observe(container.current); + return () => { + observer.disconnect(); + instance.dispose(); + chart.current = undefined; + }; + }, []); + useEffect(() => { + const palette = dark ? PALETTES.dark : PALETTES.light; + const font = getComputedStyle(document.documentElement).getPropertyValue("--font-sans").trim(); + const option: ChartOption = { + animation: false, + textStyle: { fontFamily: font, fontSize: 11, color: palette.text3 }, + grid: { left: 36, right: 12, top: 20, bottom: 28 }, + xAxis: { + type: "time", + min: from, + max: end, + splitNumber: minutes === 15 ? 4 : 6, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { + color: palette.text3, + hideOverlap: true, + formatter: (time: number) => + new Date(time).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }), + }, + }, + yAxis: { + type: "value", + min: 0, + ...(field === "tps" ? { max: 20, interval: 5 } : { minInterval: 1, splitNumber: 4 }), + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: palette.text3 }, + splitLine: { lineStyle: { color: palette.line, width: 0.5 } }, + }, + tooltip: { + trigger: "axis", + renderMode: "richText", + confine: true, + backgroundColor: palette.block, + borderColor: palette.line, + borderWidth: 1, + padding: [10, 12], + textStyle: { color: palette.text, fontFamily: font, fontSize: 12 }, + axisPointer: { type: "line", snap: true, lineStyle: { color: palette.text4, type: "dashed" } }, + formatter: (params) => { + const row = Array.isArray(params) ? params[0] : params; + if (!row || !Array.isArray(row.value)) return ""; + const [time, value] = row.value; + if (typeof time !== "number" || typeof value !== "number") return ""; + return `${fullTime(time)}\n${label} ${field === "tps" ? value.toFixed(2) : value}`; + }, + }, + series: groups.map((group, index) => ({ + id: `segment-${index}`, + name: label, + type: "line", + connectNulls: false, + smooth: false, + showSymbol: group.length === 1, + symbol: "circle", + symbolSize: 5, + lineStyle: { color: palette.progress, width: 1.8 }, + itemStyle: { color: palette.progress }, + emphasis: { disabled: true }, + data: group.map((sample) => [sample.recordedAt, sample[field]!]), + })), + }; + chart.current?.setOption(option, { notMerge: true }); + }, [dark, end, field, from, groups, label, minutes]); + return ( +
+ ); +} + +export function MetricChart({ data, field, minutes }: { data: MetricsResult; field: MetricField; minutes: number }) { + const end = data.resourcesUpdatedAt ?? data.history.at(-1)?.recordedAt ?? Date.now(); + const from = end - minutes * 60_000; + const groups = metricSegments(data.history, field, from, end); + if (!groups.length) + return ( + + ); + return ; +} diff --git a/packages/desktop/src/renderer/components/MetricsPanel.tsx b/packages/desktop/src/renderer/components/MetricsPanel.tsx new file mode 100644 index 00000000..f3f74a63 --- /dev/null +++ b/packages/desktop/src/renderer/components/MetricsPanel.tsx @@ -0,0 +1,179 @@ +/** 首页指标:服务端有界历史绘图,不补造采样;断采和重启分段展示。 */ +import type { MetricsResult } from "@sfmc-bds/management"; +import { useEffect, useState } from "react"; +import { useDesktop } from "../app/desktop.js"; +import { fullTime } from "../lib/format.js"; +import { MetricChart } from "./MetricChart.js"; +import { Segmented } from "./controls.js"; +import { EmptyState, Surface } from "./ui.js"; + +/** 对不同实例隔离轮询;失败后保留历史但不继续呈现实时数值。 */ +export function useRuntimeMetrics() { + const { model, request } = useDesktop(); + const supported = Boolean(model.handshake?.capabilities.includes("metrics")); + const [data, setData] = useState(); + const [receivedAt, setReceivedAt] = useState(0); + const [failed, setFailed] = useState(false); + const [clock, setClock] = useState(Date.now()); + useEffect(() => { + setData(undefined); + setReceivedAt(0); + setFailed(false); + if (!supported || model.disconnected) return; + let disposed = false; + let timer: ReturnType; + const poll = async () => { + try { + const value = await request("metrics.read"); + if (!disposed) { + setData(value); + setReceivedAt(Date.now()); + setFailed(false); + } + } catch { + if (!disposed) setFailed(true); + } finally { + if (!disposed) timer = setTimeout(() => void poll(), 10_000); + } + }; + void poll(); + return () => { + disposed = true; + clearTimeout(timer); + }; + }, [request, supported, model.disconnected]); + useEffect(() => { + const timer = setInterval(() => setClock(Date.now()), 5000); + return () => clearInterval(timer); + }, []); + const reachable = !model.disconnected && !failed && clock - receivedAt < 30_000; + return { + data, + supported, + reachable, + fresh: reachable && Boolean(data?.fresh), + loading: !receivedAt && !failed && !model.disconnected, + }; +} + +const memory = (value: number | null | undefined) => + value === null || value === undefined + ? "—" + : value >= 1024 + ? `${(value / 1024).toFixed(1)} GB` + : `${Math.round(value)} MB`; + +export function MetricsPanel({ metrics }: { metrics: ReturnType }) { + const [field, setField] = useState<"tps" | "onlineCount">("tps"); + const [minutes, setMinutes] = useState<"15" | "60">("15"); + const { data, supported, reachable, fresh, loading } = metrics; + const sample = fresh ? data?.current : null; + const host = reachable ? data?.host : null; + return ( + + } + > +
+
+
+ + + {sample?.tps !== null && sample?.tps !== undefined ? ( + TPS {sample.tps.toFixed(2)} / 20 + ) : ( + "" + )} + +
+ {data?.history.length ? ( + + ) : ( + + )} +
+ {data?.updatedAt ? `最后采样 ${fullTime(data.updatedAt)}` : ""} + {!fresh && data?.updatedAt ? "(实时状态未知)" : ""} +
+
+
+
+ 主机内存 + + {memory(host?.memory.usedMb)} / {memory(host?.memory.totalMb)} + +
+
+ +
+ {(["bds", "db"] as const).map((key) => { + const process = reachable ? data?.processes?.[key] : null; + return ( +
+ {key === "bds" ? "BDS" : "数据服务"} + {memory(process?.memoryMb)} + + {process + ? process.running + ? `累计 CPU ${process.cpuSeconds === null ? "未知" : `${process.cpuSeconds.toFixed(1)} 秒`}` + : "已停止" + : "资源未知"} + +
+ ); + })} +
+ 维度实体 + {[ + ["minecraft:overworld", "主世界"], + ["minecraft:nether", "下界"], + ["minecraft:the_end", "末地"], + ].map(([key, label]) => ( +
+ {label} + {sample?.entities[key!] ?? "—"} +
+ ))} +
+
+ 视距区块估算 + {sample?.chunkEstimate ?? "—"} +
+
+
+
+ ); +} diff --git a/packages/desktop/src/renderer/components/OperationSteps.tsx b/packages/desktop/src/renderer/components/OperationSteps.tsx new file mode 100644 index 00000000..0d5070df --- /dev/null +++ b/packages/desktop/src/renderer/components/OperationSteps.tsx @@ -0,0 +1,58 @@ +/** + * OperationSteps.tsx — 后台任务的阶段时间线与状态图标 + * + * 使用场景:任务记录页的展开详情、初始化向导的实时进度。阶段由守护进程按执行顺序追加, + * 这里只负责展示(名称本地化、状态图标、耗时、阶段说明)。 + */ +import type { OperationPhase, OperationRecord } from "@sfmc-bds/management"; +import { duration, phaseLabel, TASK_STATUS } from "../lib/format.js"; +import { Icon } from "./icons.js"; + +/** 任务状态图标:运行中为旋转加载,其余按状态取图标与语气 */ +export function TaskStatusIcon({ status, size = 16 }: { status: OperationRecord["status"]; size?: number }) { + const tone = TASK_STATUS[status].tone; + if (status === "running") return ; + if (status === "queued") return ; + if (status === "succeeded") return ; + if (status === "interrupted") return ; + return ; +} + +/** 单个阶段的状态图标 */ +function PhaseIcon({ phase }: { phase: OperationPhase }) { + if (phase.status === "running") return ; + if (phase.status === "done") return ; + if (phase.status === "failed") return ; + return ; +} + +/** 阶段时间线 */ +export function OperationSteps({ phases, pendingLabel = "等待维护锁并开始执行" }: { phases: OperationPhase[]; pendingLabel?: string }) { + if (!phases.length) { + return ( +
    +
  1. + +
    {pendingLabel}
    +
  2. +
+ ); + } + return ( +
    + {phases.map((phase, index) => { + const elapsed = phase.startedAt && phase.finishedAt ? Date.parse(phase.finishedAt) - Date.parse(phase.startedAt) : undefined; + return ( +
  1. + +
    + {phaseLabel(phase.name)} + {phase.message && {phase.message}} +
    + {elapsed !== undefined && {duration(elapsed)}} +
  2. + ); + })} +
+ ); +} diff --git a/packages/desktop/src/renderer/components/RightRail.tsx b/packages/desktop/src/renderer/components/RightRail.tsx new file mode 100644 index 00000000..653fb111 --- /dev/null +++ b/packages/desktop/src/renderer/components/RightRail.tsx @@ -0,0 +1,58 @@ +/** + * RightRail.tsx — 右侧"动态"栏(SnowUI Right Panel):告警、任务动态、服务状态 + * + * 使用场景:应用外壳右侧,宽 280px,可通过顶栏按钮收起(状态持久化在 App 中)。 + * 顶部 68px 为窗口拖拽区,右侧为系统窗口按钮留位;三个区块分别对应 SnowUI 的 Notifications / Activities / Contacts。 + */ +import { useEffect, useState, type ReactNode } from "react"; +import { useDesktop } from "../app/desktop.js"; +import { AlertFeed, ServiceFeed, TaskFeed } from "./feeds.js"; +import { EmptyState } from "./ui.js"; + +/** 区块标题行:标题 + 可选"查看全部"链接 */ +function RailSection({ title, action, onAction, children }: { title: string; action?: string; onAction?: () => void; children: ReactNode }) { + return ( +
+
+

{title}

+ {action && } +
+ {children} +
+ ); +} + +/** 右侧动态栏 */ +export function RightRail() { + const { model, selected, setPage, page } = useDesktop(); + const [now, setNow] = useState(Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(timer); + }, []); + const connected = Boolean(model.handshake); + return ( + + ); +} diff --git a/packages/desktop/src/renderer/components/Sidebar.tsx b/packages/desktop/src/renderer/components/Sidebar.tsx new file mode 100644 index 00000000..a0373d5e --- /dev/null +++ b/packages/desktop/src/renderer/components/Sidebar.tsx @@ -0,0 +1,121 @@ +/** + * Sidebar.tsx — 左侧栏(SnowUI Sidebar):品牌位、实例列表、分组导航、客户端信息 + * + * 使用场景:应用外壳常驻左侧,宽 212px,可通过底部按钮或 Ctrl+B 切换图标模式。 + * 顶部品牌行与标题栏同高并作为窗口拖拽区;实例列表对应 SnowUI 的 Favorites 区,导航对应 Dashboards/Pages 分组。 + */ +import type { InstanceProfile } from "../../shared/api.js"; +import { activeTasks, connectionState, useDesktop } from "../app/desktop.js"; +import { NAV, type NavItem } from "../app/nav.js"; +import { cx } from "../lib/cx.js"; +import { Icon, LogoMark } from "./icons.js"; +import { IconButton } from "./controls.js"; +import { Tooltip } from "./tooltip.js"; +import { StatusDot } from "./ui.js"; + +/** 实例的副标题:本机显示目录,远程显示主机与系统(侧栏悬浮提示、命令面板、欢迎页共用) */ +export function profileSubtitle(profile: InstanceProfile): string { + return profile.kind === "local" ? `本机 ${profile.root}` : `${profile.username ? `${profile.username}@` : ""}${profile.host}${profile.port && profile.port !== 22 ? `:${profile.port}` : ""} ${profile.os === "windows" ? "Windows" : "Linux"}`; +} + +/** 实例列表:每行一个实例(状态点 + 名称),当前实例高亮,悬停显示设置按钮 */ +function InstanceList({ collapsed }: { collapsed: boolean }) { + const { profiles, current, setCurrent, models, openProfileDialog } = useDesktop(); + return ( +
+
+ 实例 + openProfileDialog(null)} /> +
+ {profiles.length ? ( +
    + {profiles.map((profile) => { + const state = connectionState(models[profile.id]); + const active = profile.id === current; + return ( +
  • + {profile.name}
    {profileSubtitle(profile)}
    {state.label}} side="right"> + +
    + openProfileDialog(profile)} /> +
  • + ); + })} +
+ ) : ( + + + + )} +
+ ); +} + +/** 左侧栏 */ +export function Sidebar({ collapsed, onToggleSidebar, onOpenAbout }: { collapsed: boolean; onToggleSidebar: () => void; onOpenAbout: () => void }) { + const { model, page, setPage } = useDesktop(); + const running = activeTasks(model).length; + const groups = [...new Set(NAV.map((item) => item.group))]; + const disabled = (item: NavItem) => Boolean(model.handshake && !model.handshake.capabilities.includes(item.capability)); + return ( + + ); +} diff --git a/packages/desktop/src/renderer/components/TitleBar.tsx b/packages/desktop/src/renderer/components/TitleBar.tsx new file mode 100644 index 00000000..c3b8e4df --- /dev/null +++ b/packages/desktop/src/renderer/components/TitleBar.tsx @@ -0,0 +1,160 @@ +/** + * TitleBar.tsx — 顶栏(SnowUI Header):面包屑、连接状态、搜索、外观、任务、告警、右侧栏开关 + * + * 使用场景:窗口使用 titleBarStyle: "hidden" + 原生按钮覆盖层,本组件即窗口可拖拽区(高 68px); + * 右侧栏收起时通过 --wco-right(由 env(titlebar-area-*) 推导)为系统最小化/最大化/关闭按钮留位。 + */ +import { useEffect, useMemo, useState } from "react"; +import type { AppearanceMode } from "../../shared/api.js"; +import { activeTasks, connectionState, useDesktop } from "../app/desktop.js"; +import { navItem } from "../app/nav.js"; +import { useAppearance } from "../app/theme.js"; +import { duration, hostLabel, phaseLabel, taskLabel } from "../lib/format.js"; +import { Button, IconButton } from "./controls.js"; +import { AlertFeed } from "./feeds.js"; +import { Icon, type IconName } from "./icons.js"; +import { Menu, Popover } from "./overlays.js"; +import { Kbd, StatusDot } from "./ui.js"; +import { ProgressLine } from "./feedback.js"; + +/** 外观模式选项(顶栏外观菜单、命令面板共用) */ +export const APPEARANCE_OPTIONS: { value: AppearanceMode; icon: IconName; label: string }[] = [ + { value: "system", icon: "system", label: "跟随系统" }, + { value: "light", icon: "sun", label: "浅色" }, + { value: "dark", icon: "moon", label: "深色" }, +]; + +/** 连接详情弹层内容(逻辑与原实现一致:重新连接 / 断开 / 恢复连接 / 接入实例) */ +function ConnectionDetails() { + const { selected, model, connect, disconnect, setPage } = useDesktop(); + const [now, setNow] = useState(Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(timer); + }, []); + if (!selected) return null; + const handshake = model.handshake; + const state = connectionState(model); + return ( +
+
+ + {state.label} + {selected.name} +
+ {handshake ? ( +
+
平台版本
{handshake.platformVersion}
+
宿主系统
{hostLabel(handshake.host.os, handshake.host.arch)} {handshake.host.release}
+
守护进程
PID {handshake.daemonPid} 已运行 {duration(now - Date.parse(handshake.daemonStartedAt))}
+
部署目录
{handshake.root}
+
协议
v{handshake.protocolVersion}{handshake.legacy ? "(旧版)" : ""}
+
+ ) : ( +

尚未与此实例建立管理连接。

+ )} + {model.connectionMessage &&

{model.connectionMessage}

} +
+ {handshake && !model.disconnected ? ( + <> + + + + ) : handshake ? ( + + ) : ( + + )} +
+
+ ); +} + +/** 告警按钮:角标为"上次打开后新增"的警告/错误数,打开弹层即视为已读(按实例分别记录) */ +function AlertsButton() { + const { model, current, setPage } = useDesktop(); + const [seen, setSeen] = useState>({}); + const [open, setOpen] = useState(false); + const seenAt = seen[current] ?? 0; + const unread = useMemo(() => model.logs.reduce((count, log) => (log.level === "error" || log.level === "warn") && Date.parse(log.time) > seenAt ? count + 1 : count, 0), [model.logs, seenAt]); + return ( + { + setOpen(next); + if (next) setSeen((rows) => ({ ...rows, [current]: Date.now() })); + }} + width={340} + className="alerts-pop" + trigger={} + > +
+ 告警 + 最近的警告与错误日志 +
+ { setOpen(false); setPage("logs"); }} /> +
+ ); +} + +/** 顶栏 */ +export function TitleBar({ onOpenPalette, railOpen, onToggleRail }: { onOpenPalette: () => void; railOpen: boolean; onToggleRail: () => void }) { + const { selected, page, model, busy, setPage } = useDesktop(); + const { mode, dark, setMode } = useAppearance(); + const state = connectionState(model); + const running = activeTasks(model); + const latest = running[0]; + return ( +
+
+ {selected ? ( + + ) : ( + SFMC Desktop + )} + {selected && ( + + + {state.label} + + + } + align="start" + width={340} + > + + + )} +
+
+ {latest && !railOpen && ( + + )} + + } + items={APPEARANCE_OPTIONS.map((option) => ({ key: option.value, label: option.label, icon: option.icon, checked: mode === option.value, onSelect: () => setMode(option.value) }))} + width={160} + /> + setPage("tasks")} /> + + +
+ {busy && } +
+ ); +} diff --git a/packages/desktop/src/renderer/components/controls.tsx b/packages/desktop/src/renderer/components/controls.tsx new file mode 100644 index 00000000..d74de170 --- /dev/null +++ b/packages/desktop/src/renderer/components/controls.tsx @@ -0,0 +1,300 @@ +/** + * controls.tsx — 基于 Base UI 的操作与表单原语(SnowUI 视觉) + * + * 使用场景:所有页面与对话框的按钮、输入框、选择器、开关、复选框、分段控件、字段标签都从这里取用。 + * 页面只依赖这些组件的语义化属性(variant / size / options),不直接拼装 @base-ui/react 的部件结构(DIP); + * 视觉集中在 styles/controls.css(DRY)。可访问性(键盘导航、ARIA、与 Field 的标签关联)由 Base UI 提供。 + */ +import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox"; +import { Field as BaseField } from "@base-ui/react/field"; +import { Input as BaseInput } from "@base-ui/react/input"; +import { NumberField } from "@base-ui/react/number-field"; +import { Select as BaseSelect } from "@base-ui/react/select"; +import { Switch as BaseSwitch } from "@base-ui/react/switch"; +import { Tabs } from "@base-ui/react/tabs"; +import { useState, type ButtonHTMLAttributes, type CSSProperties, type InputHTMLAttributes, type ReactNode, type Ref, type RefObject } from "react"; +import { cx } from "../lib/cx.js"; +import { Icon, type IconName } from "./icons.js"; +import { Tooltip } from "./tooltip.js"; + +/** 控件尺寸:sm 24px(工具栏、行内操作)/ md 32px(默认)/ lg 40px(引导页主操作) */ +export type ControlSize = "sm" | "md" | "lg"; + +/** 按钮变体:primary 单色实心 / secondary 浅填充 / ghost 透明 / danger 红色实心 / danger-soft 红字浅底 */ +export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "danger-soft"; + +export interface ButtonProps extends Omit, "children"> { + variant?: ButtonVariant; + size?: ControlSize; + /** 前置图标 */ + icon?: IconName; + /** 后置图标(如"下一步 →") */ + iconEnd?: IconName; + /** 加载中:前置图标替换为旋转指示并禁用按钮 */ + loading?: boolean; + /** 撑满父容器宽度 */ + block?: boolean; + children?: ReactNode; + ref?: Ref; +} + +/** 文字按钮。可作为 Base UI 触发器的 render 目标(透传 ref 与事件属性) */ +export function Button({ variant = "secondary", size = "md", icon, iconEnd, loading, block, children, className, disabled, type = "button", ref, ...rest }: ButtonProps) { + const iconSize = size === "sm" ? 14 : 16; + const hasLabel = children !== undefined && children !== null && children !== false; + return ( + + ); +} + +export interface IconButtonProps extends Omit, "children"> { + icon: IconName; + /** 无障碍名称,同时作为默认悬浮提示 */ + label: string; + /** sm 24px(SnowUI 标题栏图标位)/ md 32px */ + size?: "sm" | "md"; + variant?: "ghost" | "secondary"; + /** 切换型按钮的按下状态(例如右侧栏开关) */ + active?: boolean; + danger?: boolean; + /** 角标:数字显示计数,true 显示小圆点 */ + badge?: number | boolean; + /** 自定义提示内容;false 不显示提示 */ + tooltip?: ReactNode | false; + mirrored?: boolean; + spin?: boolean; + ref?: Ref; +} + +/** 方形图标按钮:默认带悬浮提示 */ +export function IconButton({ icon, label, size = "md", variant = "ghost", active, danger, badge, tooltip, mirrored, spin, className, type = "button", ref, ...rest }: IconButtonProps) { + const button = ( + + ); + if (tooltip === false) return button; + return {button}; +} + +export interface TextInputProps extends Omit, "size" | "onChange" | "value" | "defaultValue" | "prefix"> { + value: string; + onChange?: (value: string) => void; + /** 前置图标 */ + icon?: IconName; + /** 后置内容(清除按钮、快捷键提示、单位) */ + suffix?: ReactNode; + size?: ControlSize; + /** 等宽字体(路径、XUID、端口) */ + mono?: boolean; + invalid?: boolean; + inputRef?: RefObject; + /** filled 浅填充(默认,SnowUI 搜索框样式)/ outline 描边(用于已在填充面上的输入框) */ + variant?: "filled" | "outline"; + style?: CSSProperties; +} + +/** 单行文本输入:外层容器承载图标与后缀,内层为 Base UI Input(可与 Field 自动关联标签) */ +export function TextInput({ value, onChange, icon, suffix, size = "md", mono, invalid, className, inputRef, variant = "filled", style, disabled, ...rest }: TextInputProps) { + return ( + + {icon && } + } + value={value} + disabled={disabled} + onValueChange={(next) => onChange?.(next)} + className={cx("input-field", mono && "mono")} + aria-invalid={invalid || undefined} + {...rest} + /> + {suffix && {suffix}} + + ); +} + +/** 搜索输入:放大镜图标 + 有内容时显示清除按钮,否则显示快捷键提示 */ +export function SearchInput({ value, onChange, hint, ...rest }: Omit & { onChange: (value: string) => void; hint?: ReactNode }) { + return ( + onChange("")}> + ) : hint} + onKeyDown={(event) => { + if (event.key === "Escape" && value) { event.stopPropagation(); onChange(""); } + rest.onKeyDown?.(event); + }} + /> + ); +} + +/** 密码输入:可切换明文显示 */ +export function PasswordInput(props: Omit) { + const [reveal, setReveal] = useState(false); + return ( + setReveal(!reveal)}>} + /> + ); +} + +/** 数字输入:Base UI NumberField,支持键盘上下键、范围约束与步进按钮;端口等编号默认不分组(19132 而非 19,132) */ +export function NumberInput({ value, onChange, min, max, step = 1, integer, disabled, size = "md", width, invalid, id }: { value: number | null; onChange: (value: number | null) => void; min?: number; max?: number; step?: number; integer?: boolean; disabled?: boolean; size?: ControlSize; width?: number | string; invalid?: boolean; id?: string }) { + return ( + onChange(next)} min={min} max={max} step={step} disabled={disabled} format={integer ? { useGrouping: false, maximumFractionDigits: 0 } : { useGrouping: false }} className="number-field"> + + + + + + + + + ); +} + +/** 下拉选项 */ +export interface SelectOption { + value: T; + label: ReactNode; + /** 选项下方的说明 */ + description?: ReactNode; + icon?: IconName; + disabled?: boolean; +} + +/** 下拉选择:弹层使用与菜单一致的毛玻璃样式,选中项带对勾 */ +export function Select({ value, onChange, options, placeholder = "请选择", disabled, size = "md", width, className, invalid, label, variant = "filled", id }: { value: T | undefined; onChange: (value: T) => void; options: SelectOption[]; placeholder?: string; disabled?: boolean; size?: ControlSize; width?: number | string; className?: string; invalid?: boolean; label?: string; variant?: "filled" | "outline" | "ghost"; id?: string }) { + const items = options.map((option) => ({ value: option.value, label: option.label })); + return ( + { if (next !== null && next !== undefined) onChange(next as T); }} items={items} disabled={disabled}> + + + + + + + + + {options.map((option) => ( + + {option.icon && } + + {option.label} + {option.description && {option.description}} + + + + ))} + + + + + + ); +} + +/** 开关:sm 用于列表行内,md 用于设置项 */ +export function Switch({ checked, onChange, disabled, size = "md", label, id }: { checked: boolean; onChange: (checked: boolean) => void; disabled?: boolean; size?: "sm" | "md"; label?: string; id?: string }) { + return ( + onChange(next)} disabled={disabled} className={cx("switch", `switch-${size}`)} aria-label={label}> + + + ); +} + +/** 复选框(带可点击的文字标签) */ +export function Checkbox({ checked, onChange, disabled, children }: { checked: boolean; onChange: (checked: boolean) => void; disabled?: boolean; children?: ReactNode }) { + return ( + + ); +} + +/** 分段选项 */ +export interface SegmentOption { + value: T; + label: ReactNode; + icon?: IconName; + /** 选项后的计数徽标 */ + count?: number; + /** 计数徽标语气(例如源码视图的错误数) */ + countTone?: "danger"; + disabled?: boolean; +} + +/** + * 分段控件(Base UI Tabs 实现,带滑动指示块)。 + * variant="pill":浅填充轨道 + 白色滑块(视图切换、筛选);variant="text":SnowUI 文字标签页(选中为 100% 文字色,其余 40%)。 + */ +export function Segmented({ value, onChange, options, size = "md", variant = "pill", label, className }: { value: T; onChange: (value: T) => void; options: SegmentOption[]; size?: "sm" | "md"; variant?: "pill" | "text"; label?: string; className?: string }) { + return ( + onChange(next as T)} className={cx("segmented", `segmented-${variant}`, `segmented-${size}`, className)}> + + {options.map((option) => ( + + {option.icon && } + {option.label} + {option.count !== undefined && {option.count}} + + ))} + + + + ); +} + +/** + * 表单字段:标签 + 控件 + 说明/错误。基于 Base UI Field,标签会自动关联到内部的 Base UI 控件。 + * 使用场景:实例设置、登录、初始化向导、玩家条目编辑等对话框表单。 + */ +export function Field({ label, description, error, required, children, className }: { label?: ReactNode; description?: ReactNode; error?: ReactNode; required?: boolean; children: ReactNode; className?: string }) { + return ( + + {label && ( + + {label} + {required && } + + )} + {children} + {error ? ( +
{error}
+ ) : description ? ( + {description} + ) : null} +
+ ); +} diff --git a/packages/desktop/src/renderer/components/feedback.tsx b/packages/desktop/src/renderer/components/feedback.tsx new file mode 100644 index 00000000..25a053ea --- /dev/null +++ b/packages/desktop/src/renderer/components/feedback.tsx @@ -0,0 +1,245 @@ +/** + * feedback.tsx — 全局反馈:通知(Toast)与确认对话框(Confirm) + * + * 使用场景: + * - toast:操作结果提示、后台任务进度卡片(以任务编号为 id 原地更新)。基于 Base UI 全局 ToastManager, + * 因此 DesktopProvider 的回调等非组件代码也能直接调用,调用方不依赖具体渲染细节(DIP)。 + * - useConfirm:替代 antd modal.confirm 的 Promise 式确认框,支持危险操作、强确认复选框, + * 以及 onConfirm 异步执行(执行中按钮显示加载,失败时错误留在对话框内并保持打开)。 + */ +import { AlertDialog } from "@base-ui/react/alert-dialog"; +import { Toast } from "@base-ui/react/toast"; +import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react"; +import { cx } from "../lib/cx.js"; +import { errorText } from "../lib/format.js"; +import { Button, Checkbox } from "./controls.js"; +import { Icon, type IconName } from "./icons.js"; + +/** 通知语气:progress 为进行中的后台任务(旋转图标 + 不确定进度条,默认不自动关闭) */ +export type ToastTone = "success" | "danger" | "warning" | "info" | "progress"; + +/** 通知自定义数据(随 Base UI toast 对象传递给渲染层) */ +interface ToastData { + tone: ToastTone; + /** 标题下方的次要信息(例如所属实例名) */ + meta?: ReactNode; +} + +/** 通知参数 */ +export interface ToastOptions { + /** 相同 id 再次调用会原地更新(任务进度卡片使用任务编号作为 id) */ + id?: string; + tone?: ToastTone; + title: ReactNode; + description?: ReactNode; + meta?: ReactNode; + /** 自动关闭时间(毫秒),0 表示不自动关闭;默认 progress 不关闭、danger 8 秒、其余 4.5 秒 */ + timeout?: number; + action?: { label: string; onClick: () => void }; + /** 通知被关闭时回调(用户手动关闭或超时) */ + onClose?: () => void; +} + +/** 全局通知管理器(模块级单例,由 渲染) */ +const manager = Toast.createToastManager(); + +/** 通知 API:toast.show / success / error / info / warning / close */ +export const toast = { + show(options: ToastOptions): string { + const tone = options.tone ?? "info"; + return manager.add({ + id: options.id, + title: options.title, + description: options.description, + type: tone, + timeout: options.timeout ?? (tone === "progress" ? 0 : tone === "danger" ? 8000 : 4500), + priority: tone === "danger" ? "high" : "low", + onClose: options.onClose, + actionProps: options.action ? { children: options.action.label, onClick: options.action.onClick } : undefined, + data: { tone, meta: options.meta }, + }); + }, + success: (title: ReactNode, description?: ReactNode) => toast.show({ tone: "success", title, description }), + error: (title: ReactNode, description?: ReactNode) => toast.show({ tone: "danger", title, description }), + info: (title: ReactNode, description?: ReactNode) => toast.show({ tone: "info", title, description }), + warning: (title: ReactNode, description?: ReactNode) => toast.show({ tone: "warning", title, description }), + close: (id?: string) => manager.close(id), +}; + +/** 语气 → 图标 */ +const TONE_ICON: Record, IconName> = { success: "checkCircle", danger: "xCircle", warning: "warning", info: "info" }; + +/** 通知列表(必须位于 Toast.Provider 内) */ +function ToastList() { + const { toasts } = Toast.useToastManager(); + return toasts.map((item) => { + const tone = item.data?.tone ?? "info"; + return ( + + + {tone === "progress" ? : } +
+ + {item.data?.meta &&
{item.data.meta}
} + + {tone === "progress" && } +
+ {item.actionProps && } + +
+
+ ); + }); +} + +/** 通知渲染宿主:应用根节点放置一次(右下角堆叠,悬停展开) */ +export function ToastHost() { + return ( + + + + + + + + ); +} + +/** 确认对话框参数 */ +export interface ConfirmOptions { + title: ReactNode; + /** 主要说明 */ + description?: ReactNode; + /** 次要提示(灰色小字,例如"任务提交后关闭客户端不会取消执行") */ + note?: ReactNode; + okText?: string; + cancelText?: string; + /** 危险操作:红色图标与确认按钮 */ + danger?: boolean; + icon?: IconName; + /** 强确认:需勾选此文案的复选框后才能确认 */ + acknowledge?: string; + /** 确认后执行的异步动作:执行中按钮显示加载;抛错时错误显示在对话框内并保持打开 */ + onConfirm?: () => Promise | unknown; +} +/** 打开确认框:确认(且 onConfirm 成功)返回 true,取消返回 false */ +export type ConfirmFn = (options: ConfirmOptions) => Promise; + +const ConfirmContext = createContext(null); + +/** 读取确认函数(必须位于 ConfirmProvider 内) */ +export function useConfirm(): ConfirmFn { + const value = useContext(ConfirmContext); + if (!value) throw new Error("useConfirm 必须在 ConfirmProvider 内使用"); + return value; +} + +/** 当前确认请求 */ +interface ConfirmRequest { + options: ConfirmOptions; + resolve: (value: boolean) => void; +} + +/** + * 确认框提供者:同一时刻只显示一个确认框;新的请求会以"取消"结束上一个尚未回答的请求。 + */ +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [request, setRequest] = useState(null); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [acknowledged, setAcknowledged] = useState(false); + const [error, setError] = useState(""); + const current = useRef(null); + + const confirm = useCallback( + (options) => + new Promise((resolve) => { + current.current?.resolve(false); + const next = { options, resolve }; + current.current = next; + setRequest(next); + setBusy(false); + setAcknowledged(false); + setError(""); + setOpen(true); + }), + [] + ); + /** 结束当前请求并关闭对话框 */ + const finish = (value: boolean) => { + current.current?.resolve(value); + current.current = null; + setOpen(false); + }; + const accept = async () => { + const active = current.current; + if (!active) return; + if (!active.options.onConfirm) return finish(true); + setBusy(true); + setError(""); + try { + await active.options.onConfirm(); + if (current.current === active) finish(true); + } catch (reason) { + if (current.current === active) setError(errorText(reason)); + } finally { + setBusy(false); + } + }; + + const options = request?.options; + const tone = options?.danger ? "danger" : "neutral"; + return ( + + {children} + { if (!next && !busy) finish(false); }} onOpenChangeComplete={(next) => { if (!next && !current.current) setRequest(null); }}> + + + + + {options && ( + <> +
+ +
+ {options.title} + {options.description && }>{options.description}} +
+
+ {(options.note || options.acknowledge || error) && ( +
+ {options.note &&

{options.note}

} + {options.acknowledge && {options.acknowledge}} + {error &&
{error}
} +
+ )} +
+ + +
+ + )} +
+
+
+
+
+ ); +} + +/** 不确定进度条(标题栏全局忙碌指示、加载占位) */ +export function ProgressLine({ className }: { className?: string }) { + return
; +} + +/** 居中加载指示 */ +export function Spinner({ label, size = 20 }: { label?: ReactNode; size?: number }) { + return ( +
+ + {label && {label}} +
+ ); +} diff --git a/packages/desktop/src/renderer/components/feeds.tsx b/packages/desktop/src/renderer/components/feeds.tsx new file mode 100644 index 00000000..d575d04b --- /dev/null +++ b/packages/desktop/src/renderer/components/feeds.tsx @@ -0,0 +1,113 @@ +/** + * feeds.tsx — 动态信息流:任务动态、服务状态、告警日志 + * + * 使用场景:右侧"动态"栏、服务总览页区块、标题栏告警弹层复用同一组组件(DRY), + * 版式对应 SnowUI 右侧栏的 Notifications / Activities / Contacts 条目(24px 图标色块 + 14px 正文 + 12px 元信息)。 + * 组件只接收数据与回调,不读取全局状态(迪米特法则),由调用方决定数据来源与跳转行为。 + */ +import type { LogEntryWire, OperationRecord, ServiceStatusRow } from "@sfmc-bds/management"; +import { groupAlerts } from "../lib/alerts.js"; +import { cx } from "../lib/cx.js"; +import { fullTime, phaseLabel, relativeTime, taskLabel, TASK_STATUS, type Tone } from "../lib/format.js"; +import type { IconName } from "./icons.js"; +import { EmptyState, IconTile, StatusDot } from "./ui.js"; + +/** 服务名 → 图标与一句话说明(总览页服务列表、右侧栏服务状态共用) */ +const SERVICE_META: Record = { + bds: { icon: "cube", text: "基岩版服务端" }, + db: { icon: "database", text: "数据库与 HTTP 接口" }, + qq: { icon: "message", text: "QQ 群消息互通桥" }, + llbot: { icon: "bot", text: "QQ 机器人后端" }, + tunnel: { icon: "globe", text: "内网穿透隧道" }, +}; +/** 读取服务的展示信息,未知服务回退为通用图标 */ +export function serviceMeta(name: string): { icon: IconName; text: string } { + return SERVICE_META[name] ?? { icon: "server", text: "受管服务" }; +} + +/** 服务的状态语义(运行中 / 外部进程 / 已停止) */ +export function serviceState(row: ServiceStatusRow): { tone: Tone; label: string } { + if (!row.running) return { tone: "neutral", label: "已停止" }; + if (row.ownership === "external") return { tone: "warning", label: "外部进程" }; + return { tone: "success", label: "运行中" }; +} + +/** 告警列表:错误用红色色块、警告用琥珀色块,正文取日志首行 */ +export function AlertFeed({ logs, now, limit = 6, onSelect, emptyText = "已加载日志中没有警告或错误" }: { logs: LogEntryWire[]; now: number; limit?: number; onSelect?: () => void; emptyText?: string }) { + const alerts = groupAlerts(logs, limit); + if (!alerts.length) return ; + return ( +
    + {alerts.map(({ log, count, firstTime }, index) => ( +
  • + +
  • + ))} +
+ ); +} + +/** 任务状态 → 图标 */ +function taskIcon(task: OperationRecord): IconName { + if (task.status === "running") return "loader"; + if (task.status === "queued") return "clock"; + if (task.status === "succeeded") return "check"; + if (task.status === "interrupted") return "warning"; + return "x"; +} + +/** 任务动态时间线:图标色块之间以 1px 竖线相连(SnowUI Activities 样式) */ +export function TaskFeed({ tasks, now, limit = 6, onSelect }: { tasks: OperationRecord[]; now: number; limit?: number; onSelect?: (task: OperationRecord) => void }) { + if (!tasks.length) return ; + return ( +
    + {tasks.slice(0, limit).map((task) => { + const status = TASK_STATUS[task.status]; + const phase = task.phases.at(-1); + return ( +
  • + +
  • + ); + })} +
+ ); +} + +/** 服务状态列表(SnowUI Contacts 样式:色块图标 + 名称 + 右侧状态) */ +export function ServiceFeed({ services, onSelect, stale = false }: { services: ServiceStatusRow[]; onSelect?: (row: ServiceStatusRow) => void; stale?: boolean }) { + if (!services.length) return ; + return ( +
    + {services.map((row) => { + const state = stale ? { tone: "neutral" as const, label: "状态未知" } : serviceState(row); + return ( +
  • + +
  • + ); + })} +
+ ); +} diff --git a/packages/desktop/src/renderer/components/icons.tsx b/packages/desktop/src/renderer/components/icons.tsx new file mode 100644 index 00000000..038a4a9c --- /dev/null +++ b/packages/desktop/src/renderer/components/icons.tsx @@ -0,0 +1,216 @@ +/** + * icons.tsx — 桌面端统一图标入口(基于 Phosphor Icons,MIT 许可;SnowUI 设计稿同样使用 Phosphor) + * + * 使用场景:导航、按钮、状态、空状态等所有图标位都通过 引用。 + * 页面只依赖这里的语义名称(如 "warning"、"restart"),不直接依赖 Phosphor 的具体组件名(DIP), + * 更换图标或升级图标库时只需修改 ICONS 映射;新增图标在 ICONS 中注册一项即可(OCP)。 + * 每个图标按子路径单独导入(@phosphor-icons/react/),避免开发服务器加载整个图标库。 + */ +import type { Icon as PhosphorIcon, IconWeight } from "@phosphor-icons/react"; +import { ArchiveIcon } from "@phosphor-icons/react/Archive"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react/ArrowClockwise"; +import { ArrowCounterClockwiseIcon } from "@phosphor-icons/react/ArrowCounterClockwise"; +import { ArrowDownIcon } from "@phosphor-icons/react/ArrowDown"; +import { ArrowRightIcon } from "@phosphor-icons/react/ArrowRight"; +import { ArrowSquareOutIcon } from "@phosphor-icons/react/ArrowSquareOut"; +import { ArrowsClockwiseIcon } from "@phosphor-icons/react/ArrowsClockwise"; +import { BellIcon } from "@phosphor-icons/react/Bell"; +import { BroadcastIcon } from "@phosphor-icons/react/Broadcast"; +import { BugIcon } from "@phosphor-icons/react/Bug"; +import { CaretDownIcon } from "@phosphor-icons/react/CaretDown"; +import { CaretRightIcon } from "@phosphor-icons/react/CaretRight"; +import { CaretUpDownIcon } from "@phosphor-icons/react/CaretUpDown"; +import { ChatCircleIcon } from "@phosphor-icons/react/ChatCircle"; +import { CheckIcon } from "@phosphor-icons/react/Check"; +import { CheckCircleIcon } from "@phosphor-icons/react/CheckCircle"; +import { CircleIcon } from "@phosphor-icons/react/Circle"; +import { CircleHalfIcon } from "@phosphor-icons/react/CircleHalf"; +import { CircleNotchIcon } from "@phosphor-icons/react/CircleNotch"; +import { ClockIcon } from "@phosphor-icons/react/Clock"; +import { ClockCounterClockwiseIcon } from "@phosphor-icons/react/ClockCounterClockwise"; +import { CodeIcon } from "@phosphor-icons/react/Code"; +import { CommandIcon } from "@phosphor-icons/react/Command"; +import { CopyIcon } from "@phosphor-icons/react/Copy"; +import { CpuIcon } from "@phosphor-icons/react/Cpu"; +import { CubeIcon } from "@phosphor-icons/react/Cube"; +import { DatabaseIcon } from "@phosphor-icons/react/Database"; +import { DotsThreeIcon } from "@phosphor-icons/react/DotsThree"; +import { DownloadSimpleIcon } from "@phosphor-icons/react/DownloadSimple"; +import { EraserIcon } from "@phosphor-icons/react/Eraser"; +import { EyeIcon } from "@phosphor-icons/react/Eye"; +import { FileCodeIcon } from "@phosphor-icons/react/FileCode"; +import { FolderOpenIcon } from "@phosphor-icons/react/FolderOpen"; +import { FunnelIcon } from "@phosphor-icons/react/Funnel"; +import { GearSixIcon } from "@phosphor-icons/react/GearSix"; +import { GlobeIcon } from "@phosphor-icons/react/Globe"; +import { HardDriveIcon } from "@phosphor-icons/react/HardDrive"; +import { HardDrivesIcon } from "@phosphor-icons/react/HardDrives"; +import { ImageIcon } from "@phosphor-icons/react/Image"; +import { InfoIcon } from "@phosphor-icons/react/Info"; +import { KeyIcon } from "@phosphor-icons/react/Key"; +import { LightningIcon } from "@phosphor-icons/react/Lightning"; +import { LinkIcon } from "@phosphor-icons/react/Link"; +import { ListBulletsIcon } from "@phosphor-icons/react/ListBullets"; +import { ListChecksIcon } from "@phosphor-icons/react/ListChecks"; +import { LockIcon } from "@phosphor-icons/react/Lock"; +import { MagnifyingGlassIcon } from "@phosphor-icons/react/MagnifyingGlass"; +import { MinusIcon } from "@phosphor-icons/react/Minus"; +import { MonitorIcon } from "@phosphor-icons/react/Monitor"; +import { MoonIcon } from "@phosphor-icons/react/Moon"; +import { PackageIcon } from "@phosphor-icons/react/Package"; +import { PencilSimpleIcon } from "@phosphor-icons/react/PencilSimple"; +import { PlayIcon } from "@phosphor-icons/react/Play"; +import { PlugIcon } from "@phosphor-icons/react/Plug"; +import { PlugsIcon } from "@phosphor-icons/react/Plugs"; +import { PlusIcon } from "@phosphor-icons/react/Plus"; +import { PowerIcon } from "@phosphor-icons/react/Power"; +import { PulseIcon } from "@phosphor-icons/react/Pulse"; +import { PuzzlePieceIcon } from "@phosphor-icons/react/PuzzlePiece"; +import { RobotIcon } from "@phosphor-icons/react/Robot"; +import { RocketLaunchIcon } from "@phosphor-icons/react/RocketLaunch"; +import { ShieldCheckIcon } from "@phosphor-icons/react/ShieldCheck"; +import { ShieldWarningIcon } from "@phosphor-icons/react/ShieldWarning"; +import { SidebarSimpleIcon } from "@phosphor-icons/react/SidebarSimple"; +import { SlidersHorizontalIcon } from "@phosphor-icons/react/SlidersHorizontal"; +import { SparkleIcon } from "@phosphor-icons/react/Sparkle"; +import { SquaresFourIcon } from "@phosphor-icons/react/SquaresFour"; +import { StackIcon } from "@phosphor-icons/react/Stack"; +import { StopIcon } from "@phosphor-icons/react/Stop"; +import { SunIcon } from "@phosphor-icons/react/Sun"; +import { TerminalWindowIcon } from "@phosphor-icons/react/TerminalWindow"; +import { TextAlignLeftIcon } from "@phosphor-icons/react/TextAlignLeft"; +import { TrashIcon } from "@phosphor-icons/react/Trash"; +import { UploadSimpleIcon } from "@phosphor-icons/react/UploadSimple"; +import { UserIcon } from "@phosphor-icons/react/User"; +import { UsersThreeIcon } from "@phosphor-icons/react/UsersThree"; +import { WarningIcon } from "@phosphor-icons/react/Warning"; +import { WarningCircleIcon } from "@phosphor-icons/react/WarningCircle"; +import { XIcon } from "@phosphor-icons/react/X"; +import { XCircleIcon } from "@phosphor-icons/react/XCircle"; +import { useId, type CSSProperties } from "react"; + +/** 语义名称 → Phosphor 图标组件 */ +const ICONS = { + overview: SquaresFourIcon, + terminal: TerminalWindowIcon, + modules: PuzzlePieceIcon, + sliders: SlidersHorizontalIcon, + package: PackageIcon, + users: UsersThreeIcon, + user: UserIcon, + refresh: ArrowsClockwiseIcon, + history: ClockCounterClockwiseIcon, + tasks: ListChecksIcon, + archiveRestore: ArrowCounterClockwiseIcon, + search: MagnifyingGlassIcon, + plus: PlusIcon, + minus: MinusIcon, + x: XIcon, + check: CheckIcon, + chevronDown: CaretDownIcon, + chevronRight: CaretRightIcon, + chevronsUpDown: CaretUpDownIcon, + arrowRight: ArrowRightIcon, + arrowDown: ArrowDownIcon, + play: PlayIcon, + stop: StopIcon, + restart: ArrowClockwiseIcon, + power: PowerIcon, + monitor: MonitorIcon, + server: HardDrivesIcon, + globe: GlobeIcon, + database: DatabaseIcon, + message: ChatCircleIcon, + bot: RobotIcon, + cube: CubeIcon, + plug: PlugIcon, + unplug: PlugsIcon, + warning: WarningIcon, + alert: WarningCircleIcon, + info: InfoIcon, + checkCircle: CheckCircleIcon, + xCircle: XCircleIcon, + circle: CircleIcon, + loader: CircleNotchIcon, + clock: ClockIcon, + copy: CopyIcon, + download: DownloadSimpleIcon, + upload: UploadSimpleIcon, + trash: TrashIcon, + pencil: PencilSimpleIcon, + more: DotsThreeIcon, + external: ArrowSquareOutIcon, + sun: SunIcon, + moon: MoonIcon, + system: CircleHalfIcon, + settings: GearSixIcon, + command: CommandIcon, + wrap: TextAlignLeftIcon, + eraser: EraserIcon, + folder: FolderOpenIcon, + key: KeyIcon, + shield: ShieldCheckIcon, + shieldAlert: ShieldWarningIcon, + cpu: CpuIcon, + activity: PulseIcon, + archive: ArchiveIcon, + fileCode: FileCodeIcon, + sparkles: SparkleIcon, + rocket: RocketLaunchIcon, + link: LinkIcon, + zap: LightningIcon, + hardDrive: HardDriveIcon, + code: CodeIcon, + list: ListBulletsIcon, + layers: StackIcon, + image: ImageIcon, + lock: LockIcon, + eye: EyeIcon, + filter: FunnelIcon, + bell: BellIcon, + bug: BugIcon, + broadcast: BroadcastIcon, + panel: SidebarSimpleIcon, +} satisfies Record; + +/** 已注册的图标名称 */ +export type IconName = keyof typeof ICONS; + +/** 图标字重:regular 用于常规界面,duotone 用于侧栏导航(SnowUI 规范),fill 用于选中/状态强调 */ +export type IconTone = Extract; + +/** + * 图标组件。 + * @param size 像素尺寸,默认 16(SnowUI 中按钮/列表内图标为 16,导航为 20) + * @param weight 字重,默认 regular + * @param spin 是否旋转(用于加载中) + * @param mirrored 是否水平镜像(例如右侧栏开关图标) + */ +export function Icon({ name, size = 16, weight = "regular", spin, mirrored, className, style }: { name: IconName; size?: number; weight?: IconTone; spin?: boolean; mirrored?: boolean; className?: string; style?: CSSProperties }) { + const Glyph = ICONS[name]; + return
} /> + + + + + ); +} diff --git a/packages/desktop/src/renderer/components/tooltip.tsx b/packages/desktop/src/renderer/components/tooltip.tsx new file mode 100644 index 00000000..19f51b0c --- /dev/null +++ b/packages/desktop/src/renderer/components/tooltip.tsx @@ -0,0 +1,32 @@ +/** + * tooltip.tsx — 悬浮提示(Base UI Tooltip 封装) + * + * 使用场景:图标按钮的名称提示、禁用控件的原因说明、截断文本的完整内容。 + * 单独成文件是为了让 controls.tsx(IconButton)与 overlays.tsx 都能依赖它而不形成循环引用。 + */ +import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip"; +import type { ReactElement, ReactNode } from "react"; + +/** 全局提示节奏:首次悬停 450ms 后出现,相邻提示之间切换时立即显示(应用根节点提供一次) */ +export function TooltipProvider({ children }: { children: ReactNode }) { + return {children}; +} + +/** + * 悬浮提示。 + * @param content 提示内容;为空时直接渲染 children(不包裹) + * @param wrap 用 包裹触发元素:禁用的原生按钮不触发指针事件,需要包裹才能显示禁用原因 + */ +export function Tooltip({ content, children, side = "top", wrap }: { content: ReactNode; children: ReactElement; side?: "top" | "bottom" | "left" | "right"; wrap?: boolean }) { + if (content === undefined || content === null || content === false || content === "") return children; + return ( + + {wrap ? }>{children} : } + + + {content} + + + + ); +} diff --git a/packages/desktop/src/renderer/components/ui.tsx b/packages/desktop/src/renderer/components/ui.tsx new file mode 100644 index 00000000..fd3f784a --- /dev/null +++ b/packages/desktop/src/renderer/components/ui.tsx @@ -0,0 +1,183 @@ +/** + * ui.tsx — 桌面端通用展示组件(SnowUI 视觉) + * + * 使用场景:各页面共用的页头、区块(Block)、徽章、状态点、空状态、提示条、统计卡片等。 + * 视觉细节集中在 styles/components.css,页面只组合这些组件,避免各处重复拼装样式(DRY)。 + * IconButton 的实现位于 controls.tsx(Base UI 原语层),这里转出以保持页面既有的导入路径不变。 + */ +import { useState, type CSSProperties, type ReactNode } from "react"; +import { cx } from "../lib/cx.js"; +import type { Tone } from "../lib/format.js"; +import { Icon, type IconName } from "./icons.js"; +import { Tooltip } from "./tooltip.js"; + +export { IconButton } from "./controls.js"; + +/** 页头:标题(14px Semibold,SnowUI 规范)+ 描述 + 右侧操作区 */ +export function PageHeader({ title, description, actions, meta }: { title: ReactNode; description?: ReactNode; actions?: ReactNode; meta?: ReactNode }) { + return ( +
+
+

{title}

+ {description &&

{description}

} + {meta &&
{meta}
} +
+ {actions &&
{actions}
} +
+ ); +} + +/** + * 区块(SnowUI Block):#F9F9FA 底色、20px 圆角、24px 内边距,可选标题行。 + * @param flush 内容区不加内边距(列表行贴边,由行自身控制留白) + */ +export function Surface({ title, description, extra, children, className, flush, icon, style }: { title?: ReactNode; description?: ReactNode; extra?: ReactNode; children?: ReactNode; className?: string; flush?: boolean; icon?: IconName; style?: CSSProperties }) { + return ( +
+ {(title || extra) && ( +
+
+ {icon && } +
+ {title &&

{title}

} + {description &&

{description}

} +
+
+ {extra &&
{extra}
} +
+ )} + {children !== undefined &&
{children}
} +
+ ); +} + +/** 状态点:pulse 用于"进行中"的呼吸效果 */ +export function StatusDot({ tone = "neutral", pulse }: { tone?: Tone; pulse?: boolean }) { + return